Administration#
By default this app has no authentication. It is local-first: one container, one SQLite file, one machine. Everyone who can reach the port can do everything. That is a deliberate trade — it keeps the app simple and it is the right shape for something running on your own laptop or on a home LAN.
Sign-in with OIDC is available as an option, and is what makes the admin panel possible. It is off unless configured.
Do not put this on the public internet as-is
Without sign-in there is no login, no rate limiting and no CSRF token on the JSON API. If you need it reachable from outside your network, put it behind a reverse proxy that does the authentication (basic auth is enough) and terminate TLS there. See Installation.
Turning on OIDC sign-in covers who you are, not the rest: the JSON API
still has no CSRF token, and RT_AUTH_REQUIRE_LOGIN=false (the default)
leaves every page readable without signing in.
Profiles#
A profile is a student. It owns assessments, attempts, solves, stars, focus items, drill sessions, plan progress and the cached scores — everything except the algorithm library, which is shared.
The first boot creates a default profile called Me with the slug me and
the goal sub_20. The active profile lives in the session cookie, so switching
profiles is per browser, not per machine.
Managing profiles#
From the profile chip in the header:
- Switch — pick another profile. Takes effect immediately.
- New — name and goal. The slug is derived from the name and de-duplicated
(
alex,alex-2, …). - Rename — changes the display name; the slug stays put so links and CLI commands keep working.
- Goal — one of the keys under
targets.splits. This selects the split targets every score is measured against, so a coach can keep a sub-30 and a sub-12 student on one machine and both see sensible colours. - Delete — removes the profile and everything it owns. The default profile cannot be deleted; promote another one first.
Using profiles for coaching#
One profile per student is the obvious use. Two less obvious ones:
- A scratch profile. Handy when you want to poke at the API console or try a plan without polluting real data.
- A "cold" profile. Some coaches keep a second profile per student with no assessments at all, to re-measure recognition from scratch every few months.
Resetting student data#
Two levels, both destructive, both irreversible without a backup.
Reset a profile keeps the profile and deletes every row it owns — assessments, attempts, solves, stars, focus, plan progress, cached scores and drill sessions.
docker compose run --rm web flask reset-profile me
There is no --force; the command is short and explicit on purpose. The same
action is available from the profile page with a confirmation dialog.
Reset everything means throwing away the database. Stop the app, remove the volume, start it again:
docker compose down
docker volume rm rubiks-trainer_rt-data
docker compose up -d
The library is re-imported and a fresh default profile is created on first boot.
Backups#
Everything a student owns is in one SQLite file on the rt-data volume, at
/data/rubiks-trainer.db by default.
Taking a backup#
Use SQLite's own backup command rather than copying the file, so you get a consistent snapshot even while the app is running:
docker compose exec web \
python -c "import sqlite3,sys; \
src=sqlite3.connect('/data/rubiks-trainer.db'); \
dst=sqlite3.connect('/data/backup.db'); \
src.backup(dst); dst.close(); src.close()"
docker compose cp web:/data/backup.db ./rubiks-trainer-$(date +%F).db
Restoring#
Stop the app first — restoring under a running server will corrupt the open connection's view of the file.
docker compose down
docker compose cp ./rubiks-trainer-2026-08-12.db web:/data/rubiks-trainer.db
docker compose up -d
What to keep#
| File | Why |
|---|---|
/data/rubiks-trainer.db |
All student data. This is the one that matters |
app/config/config.yml |
Your targets, scrambler settings and colours |
.env |
Port, secret key, database URL |
The algorithm library and the built-in plans are re-created from
app/data/algorithms.json on every boot, so they do not need backing up.
If you use Postgres
Point RT_DATABASE_URL at Postgres and back it up with pg_dump like any
other database. The schema is created automatically on first boot.
Seeding and updating the library#
The algorithm library is imported from app/data/algorithms.json at startup if
the database is empty. To re-import after the data file changes:
docker compose run --rm web flask seed
By default this is additive and idempotent — existing cases are matched by
key and updated, new ones are inserted. To wipe the library and rebuild it
from the file:
docker compose run --rm web flask seed --force
Student data is keyed on the case row, so --force will orphan attempts and
assessments if case keys change. Take a backup first, and prefer the plain
seed unless you know keys were renamed.
Regenerating the data file from the PDFs#
The library is extracted from the algorithm sheets in pdf/:
docker compose run --rm web python scripts/extract_algorithms.py
This rewrites app/data/algorithms.json and validates every case — the setup
moves followed by the primary algorithm must return the cube to the phase's
target state. Cases that fail are reported and the script exits non-zero. See
Contributing before you commit the result.
Upgrading#
See Installation → Upgrading. In short: pull,
rebuild, restart. The schema is created with create_all() and there is no
migration framework, so a release that changes a column requires a note in the
changelog and, in the worst case, a rebuild from a backup.
Health checks and monitoring#
GET /healthz returns {"status": "ok", "version": "..."} and is what the
Compose health check calls every 30 seconds:
curl -s http://localhost:8080/healthz
Application logs go to stdout and are visible with docker compose logs -f web.
Gunicorn writes both access and error logs there.
Configuration#
Every key in config.yml and every RT_* environment variable is documented
in Configuration.
Sign-in (OIDC)#
Optional, and off unless configured. With it on, each person who signs in gets their own profile; the local profile switcher stays for the case where nobody has.
Any standard OIDC provider works — the app reads the issuer's
.well-known/openid-configuration and lets Authlib
validate the ID token, so a provider is configuration rather than code.
Keycloak#
RT_AUTH_ENABLED=true
RT_OIDC_KEYCLOAK_ENABLED=true
RT_OIDC_KEYCLOAK_ISSUER=https://auth.example.org/realms/your-realm
RT_OIDC_KEYCLOAK_CLIENT_ID=rubiks-trainer-web
RT_OIDC_KEYCLOAK_CLIENT_SECRET=…
The issuer is https://<host>/realms/<realm> — the app appends the
well-known path itself, so do not include it.
Every provider takes the same six variables, where <PROVIDER> is its key
under auth.providers:
| Variable | Overrides |
|---|---|
RT_OIDC_<PROVIDER>_ENABLED |
whether it is offered |
RT_OIDC_<PROVIDER>_ISSUER |
the issuer URL |
RT_OIDC_<PROVIDER>_CLIENT_ID |
the client id |
RT_OIDC_<PROVIDER>_CLIENT_SECRET |
the secret — keep it here, never in a file |
RT_OIDC_<PROVIDER>_SCOPES |
the requested scopes |
RT_OIDC_<PROVIDER>_LABEL |
the name on the sign-in button |
Each overrides config.yml, so a deployment need never edit that file — which
matters because it is the same file for every instance, while these differ per
environment.
Scopes are usually the reason a group claim never arrives. Keycloak only puts a group in the token if the client scope carrying it was requested, so add it:
RT_OIDC_KEYCLOAK_SCOPES=openid profile email your-groups-scope
On the Keycloak client, set the valid redirect URI to:
https://<your-host>/auth/callback/keycloak
That URL is built from the request, which is why the
X-Forwarded-Proto setting
matters: without it the app builds an http:// redirect URI behind an
https:// proxy, and the provider rejects it as a mismatch.
Google#
The google provider block ships as a placeholder — enabled is false and
the credentials are blank, so it is not offered. Fill it in to turn it on:
RT_OIDC_GOOGLE_ENABLED=true
RT_OIDC_GOOGLE_CLIENT_ID=….apps.googleusercontent.com
RT_OIDC_GOOGLE_CLIENT_SECRET=…
Redirect URI: https://<your-host>/auth/callback/google. The issuer is
already set. A provider that is enabled but missing its client id is skipped
rather than shown as a button that fails, so a half-filled block is harmless.
Google sends no group claim, so a Google account is never an admin by claim.
Who is an admin#
Whoever arrives with RT_AUTH_ADMIN_VALUE among the values of the
RT_AUTH_ADMIN_CLAIM claim in their ID token:
RT_AUTH_ADMIN_CLAIM=group
RT_AUTH_ADMIN_VALUE=rubiks-trainer-admins
Check what your realm actually puts in the token — group and groups are
both common, and the wrong name silently matches nobody. Keycloak writes group
paths with a leading slash (/rubiks-trainer-admins); that matches too, as do
comma- or space-separated lists and a single string instead of an array.
This is re-read on every sign-in and never stored as a standing grant, so removing someone from the group in the provider takes the panel away the next time they sign in. It does not end a session already open.
When nobody is an admin#
The usual first problem, and it is circular: the group claim is not mapped yet, so nobody is an admin, so nobody can reach the panel to notice. The Admin link is simply absent, with no error anywhere.
Every sign-in now logs what it decided:
$ docker compose logs web | grep oidc
WARNING in auth: oidc: Luebeck signed in, not an admin. Looked for
'rubiks-trainer-admins' in claim 'group'; that claim held None.
Claims present: aud, exp, iss, name, preferred_username, sub.
That line names the claims your realm actually sends, which is what you need to
fix the mapper — if the group arrives in a claim called something else, point
RT_AUTH_ADMIN_CLAIM at it. Claim names are logged rather than their values,
since a token carries personal data; the configured group claim is the one
exception, because it is the thing being diagnosed.
To break the circle, name yourself directly:
RT_AUTH_ADMIN_USERS=maglub
A comma-separated list matched against sub, preferred_username or email —
whichever your realm sends. Anyone listed is an admin whatever the claims say.
It is still re-evaluated at every sign-in, so removing a name takes the panel
away exactly as removing a group does.
Clear it once the group claim works. A username is not a durable identity — a realm can reassign one — so this is for bootstrapping and small instances.
Requiring sign-in#
RT_AUTH_REQUIRE_LOGIN=true redirects every page to the provider. The sign-in
flow itself, /healthz, /robots.txt and static files stay open, or there
would be no way in and the container health check would fail.
Identity#
An account is (issuer, subject), not the email address. An email can be
reassigned to a different person and in some realms changed by the user; sub
is the one claim a provider promises is stable.
A first sign-in therefore creates a new, empty profile. The app will not join it to an existing profile by matching email addresses — guessing that two identities are the same person is how somebody else's history ends up under your name. Moving a history across is the admin panel's copy, below.
The admin panel#
At /admin, visible to admins only. Without sign-in configured it is 404
rather than 403: there is no way to become an admin, so the honest answer is
that the page is not there.
It lists every profile with a per-table row count and which account, if any, it belongs to — enough to tell at a glance which profile holds the history and which is the empty one that a first sign-in just created.
Copying a history to another profile#
The case this exists for: you practised as the local default profile, then signed in and got a fresh empty one.
Pick the source and the target, type the target's slug to confirm, and every assessment, attempt, solve, play count, star, focus, plan progress and drill session is copied across.
This replaces the target
Everything the target holds is deleted first, so it ends up looking like the source rather than like a merge of the two. It cannot be undone — back the database up first if the target has anything worth keeping.
The profiles themselves are untouched: names, slugs and sign-in links all survive, and the target keeps its own account. Only history moves.
Typing the slug is the confirmation rather than a checkbox, because the two dropdowns look alike and the wrong choice deletes the wrong history.
The database browser#
At /admin/db-browser, behind the same gate as the rest of the panel. It
answers one question: where is this actually stored? — without a shell on the
container and a sqlite3 session.
The index lists every table the engine reports, with its primary key, its column count and how many rows it holds. Clicking one shows the rows, 50 at a time.
On a table page you can:
- sort by any column — click the heading, click again to reverse;
- search the text columns for a substring, or type a number to jump
straight to that
id; - hover a value that was cut short to see more of it.
The schema is read from the live database rather than from models.py, so a
table that exists only on disk still shows up. Long values are truncated for
display and NULL is shown in grey italics, told apart from an empty string.
It shows everything, to anyone who is an admin
Every profile's practice history and every signed-in account's email address and provider subject is one click away. Being an admin is already enough to read all of it — the panel copies histories between profiles — but this page makes it immediate, so keep the admin group small.
It is read-only. Nothing on these pages writes, and there is no path that
could: the queries are built with select() alone, in
app/services/db_browser.py. Changing a row still means the shell,
deliberately.
The local test account#
A way into an authenticated instance that does not involve a provider: one account, one shared secret, admin rights. It exists so the admin panel and everything else behind sign-in can be exercised on a laptop, in CI, or by an agent driving a browser, without standing up Keycloak first.
Never set this on an instance real people use
Anyone holding the string is an admin. There is no rate limit in front of
it, no expiry, and no second factor. It belongs in a development .env
and nowhere else.
Turning it on#
Three things, all deliberate:
# 1. Sign-in has to be on at all.
RT_AUTH_ENABLED=true
# 2. A secret, at least 32 characters, in .env.
python -c "import secrets; print(secrets.token_urlsafe(32))"
# RT_TEST_USER_TOKEN=<paste>
# 3. The account row, once per database.
docker compose run --rm web flask create-test-user
Then sign in at /auth/test-login.
Why three things and not one#
Each one closes a different way this could go wrong by accident.
| Without it | |
|---|---|
RT_AUTH_ENABLED=true |
A single stray variable would flip a local-first instance into an authenticated one |
| A 32-character minimum | RT_TEST_USER_TOKEN=test would be a guessable admin password |
| The account row | A token that leaks into the wrong environment finds nothing to sign in to |
The route is 404 unless the first two hold — a feature that is off should
look absent rather than forbidden, and this one should not answer differently
depending on how close a guess was. Every attempt, successful or not, is
logged at warning with the client's address.
Using it#
The token is read from a form field, an X-RT-Test-Token header, or ?token=,
in that order.
# A form, in a browser
open "http://localhost:8080/auth/test-login"
# A header, for scripts
curl -c jar -X POST -H "X-RT-Test-Token: $RT_TEST_USER_TOKEN" \
http://localhost:8080/auth/test-login
curl -b jar http://localhost:8080/admin/db-browser
The query string works too, because handing a URL to a browser is sometimes the only way in — but it lands in the access log and the browser's history, so it logs a warning saying so. Prefer the other two.
What the account is#
An ordinary user_account row with provider = "local" and
issuer = "local-test", holding its own fresh profile. It is never joined to
an existing profile: guessing that two identities are the same person is how
somebody else's history ends up under your name, and the test account is not
the exception to that.
is_admin is set on the row and stays there. For a real provider the flag is
re-read from the ID token at every sign-in, so removing a group in the realm
takes the panel away — there is no token here to re-read, so deleting the
account is how you revoke it:
docker compose run --rm web flask delete-test-user
Rotating the secret is the other half: change RT_TEST_USER_TOKEN and restart.
Sessions already signed in stay signed in until they sign out, the same as for
a real provider.