# {{name}}
A `rustio-admin` project. Generated by `rustio startproject {{name}}`.
## Quickstart
```sh
# 1. Create the database
createdb {{name}}_dev
# 2. Configure DATABASE_URL (only needed if your local Postgres user/password differs)
cp .env.example .env
$EDITOR .env
# 3. Apply migrations and create the first administrator
cargo install rustio-admin-cli
rustio migrate apply
rustio user create --email admin@{{name}}.local --role administrator
# The CLI prompts twice for the password (echo-suppressed). The
# plaintext value never reaches argv, ps output, or shell history.
# Use --password '...' only inside CI or scripted bootstrap.
# 4. Boot the admin
cargo run
```
The admin lands at <http://127.0.0.1:8000/admin>. Sign in with the
account from step 3.
### Port already in use?
`src/main.rs` binds to `127.0.0.1:8000`. If that port is taken on
your machine, change the listen address in the file:
```rust
let addr = "127.0.0.1:8001".parse().expect("valid listen address");
```
Pick any free port; nothing else in the framework cares about the
exact value. Re-run `cargo run`.
## What you get after first login
- Session-backed admin authentication (Argon2id passwords; cookies
carry random tokens, the database stores SHA-256 hashes only).
- Postgres-backed users, groups, and per-model permissions (5-tier
role ladder: User · Staff · Supervisor · Administrator · Developer).
- Permission matrix UI on the group edit page (model × action grid
with a per-row "All" toggle).
- Audit history at `/admin/history` — every authority mutation
recorded with actor, target, IP, correlation id.
- Active sessions page at `/admin/account/sessions` — every signed-in
device with IP, OS · browser, created-at, last-seen.
- Correlation IDs on every request, surfaced in the
`x-correlation-id` response header and threaded into audit rows.
- FK-hydrated list cells — `belongs_to` columns render as the target
row's display field with click-throughs.
- Server-rendered templates baked into the binary; set
`RUSTIO_TEMPLATE_DIR=templates` to override any page on disk.
## Adding a model
```sh
rustio startapp comment # generates src/comment.rs +
# migrations/<NNNN>_create_comments.sql
```
The CLI prints the exact `mod` / `use` / `.model::<>()` lines to
paste into `src/main.rs`. After that, `rustio migrate apply` and
re-run; the `/admin/comments` pages light up.
> **Note:** `migrations/0001_create_posts.sql` already exists from
> the scaffold. Two valid paths from here: **(A)** keep the demo
> `Post` as a smoke test and add new migrations as `0002_*.sql`,
> `0003_*.sql`, … via `rustio startapp`; or **(B)** replace the demo
> with your real domain — delete `migrations/0001_create_posts.sql`
> and `src/post.rs`, remove the `Post` registration from
> `src/main.rs`, and write your real `0001_*.sql` instead. **Do this
> before the demo migration has been applied to a real database.**
> Once it has run in production, treat migrations as append-only and
> write a forward `0002_drop_posts.sql` instead.
## Project philosophy
- **Postgres-first.** Every query and every migration assumes
Postgres semantics.
- **Operational clarity over magic.** No risk scoring, no AI
heuristics, no automatic decisions the operator can't read.
- **Explicit model registration.** Models are listed by hand in
`src/main.rs`; the admin sidebar matches the source code.
- **Server-rendered admin UI.** One Rust binary serves everything —
no SPA, no separate frontend build step.
- **Security and auditability built into the lifecycle.** Authority
guards, hashed-at-rest sessions, centralised invalidation, audit
forensic chain — baked in.
- **No AI, no cloud lock-in, no frontend build step.** The same
binary deploys to a $5 VPS, a kubernetes cluster, or an air-gapped
factory floor.
For the full walkthrough see
[`docs/getting-started.md`](https://github.com/abdulwahed-sweden/rustio-admin/blob/main/docs/getting-started.md)
and the
[`ModelAdmin` reference](https://github.com/abdulwahed-sweden/rustio-admin/blob/main/docs/modeladmin.md).
## Project layout
| Path | Purpose |
|---|---|
| `src/main.rs` | Boots the admin: connects to Postgres, applies migrations, registers models, mounts `/admin/*`. |
| `src/post.rs` | Demo `Post` model with a populated `ModelAdmin` impl. Rename or delete when you stop needing the example. |
| `migrations/` | Numerically prefixed `*.sql` files. `rustio migrate apply` runs every pending one transactionally. |
| `templates/admin/*.html` (optional) | Project-side overrides of any framework template. Set `RUSTIO_TEMPLATE_DIR=templates` to enable. |
## Operator commands
```sh
rustio doctor # health check
rustio migrate status # see what's applied / pending
rustio user list # show accounts
rustio user role --email <e> administrator # promote
rustio group create --name editors # create a group
rustio perm grant-group --group editors --perm posts.add_post
```
## Multi-factor authentication (R3)
The framework ships TOTP-based MFA. Each user enrols at
`/admin/account/mfa/enroll` from their account menu:
1. The page renders an `otpauth://totp/...` QR code + a manual
setup key (base32) — paste into any RFC 6238-compatible
authenticator (Google Authenticator, 1Password, Bitwarden).
2. The user enters their first 6-digit code to confirm.
3. The framework AES-256-GCM-encrypts the secret with
`RUSTIO_SECRET_KEY` (from `.env`) and stores it in
`rustio_users.mfa_secret_ciphertext`. The plaintext never
reaches stable storage.
4. 8 single-use backup codes print exactly once — record them
out-of-band.
To make MFA mandatory for new users, add this line to the admin
chain in `src/main.rs`:
```rust
let admin = Admin::new()
.require_mfa(rustio_admin::auth::MfaPolicy::Required)
.model::<Post>();
```
Other policies: `Optional` (default), `RequiredForRoles(&[Role::Administrator])`,
`Disabled`.
`RUSTIO_SECRET_KEY` MUST be set whenever a user has MFA enabled.
See `.env.example` for the generation command.
## Emergency recovery (R4 CLI)
When every in-band recovery path is closed — the user forgot
their password AND lost their TOTP device AND there's no other
admin to drive an R2 reset — the operator with shell access to
the database runs:
```sh
# Set a new password + force rotation on next login. Revokes
# every active session.
rustio user reset-password \
--email alice@{{name}}.local \
--reason "Lost MFA device + locked out; no other admins available"
# Clear an auto-throttle lock.
rustio user unlock --email alice@{{name}}.local --reason "..."
# Drop MFA on a user (they re-enrol on next login if policy
# is Required).
rustio user disable-mfa --email alice@{{name}}.local --reason "..."
# Change a user's role. Refuses to demote the sole admin.
rustio user promote --email alice@{{name}}.local \
--to-role administrator --reason "..."
# Issue a single-use password-reset URL (no email needed).
# Hand the URL to the target out-of-band.
rustio user emergency-access --email alice@{{name}}.local \
--reason "..." --ttl-minutes 30
```
Every command renders a red ANSI confirmation banner before
mutating. Each writes one `rustio_admin_actions` row with
`action_type = "emergency_recovery"` so the audit trail shows
the operator went around every other tier. Pass `--yes` to skip
the prompt in scripts.
## Adding a custom route
`register_admin_routes` mounts the framework's admin surface.
Routes you want OUTSIDE that surface go directly on the
`Router` — mount them BEFORE `register_admin_routes` so the
framework's `/admin/*` wildcards never shadow them. Routes
share the same middleware chain (CSRF, correlation_id) as the
admin pages.
```rust
let db_for_inbox = db.clone();
let router = router.get("/inbox", move |req| {
let db = db_for_inbox.clone();
async move {
// Your handler. Read DB via `db.pool()`. Read the CSRF
// token off `req.ctx().get::<CsrfGuard>()`. Build a
// response via `Response::html(...)` or `::text(...)`.
Ok(rustio_admin::Response::text("project-specific page"))
}
});
// Then mount the admin surface after your custom routes.
let router = register_admin_routes(router, admin, db, templates);
```
Want the custom route to require login? Either invoke the
framework's session lookup (`auth::session_token_from_cookie` +
`auth::identity_from_session`) at the top of your handler, or
write a small project-side helper like lursystem's
`auth_helper::require_role`. The framework's `login_guard` and
`role_guard` are currently `pub(crate)`; the public API will
expose them when the `pub guards` module lands.