In June, I presented Runique 2.1 here, a Rust web framework inspired by Django, built on Axum, SeaORM, and Tera. Version 3.0 followed. Today, 4.0 is released, and this is the version where I didn't just add features: I reworked the three components everything else depends on: the admin, permissions, and forms.
Here's what changes, and why.
1. The model remains the single source of truth
This doesn't change; it's the starting point for everything: a model is declared once.
use runique::prelude::*;
model! {
Blog,
table: "blog",
pk: id => Pk,
enums: {
BlogStatus: [Draft="Draft", Published="Published", Archived="Archived"],
},
{
title: text [required],
email: email [required],
website: url [nullable],
summary: textarea [rows: 3, required],
content: richtext [rows: 15, required],
status: choice [enum(BlogStatus), default: "Draft", required],
view_count: int [nullable, default: 0],
}
}
From this, the SeaORM entity, SQL migration, and a typed form are generated. Version 4.0 adds types (audio, video), and requires you to declare what was previously implicit: the precision of a decimal, the ON DELETE action of a foreign key. A decision that affects the data should not be made on your behalf.
2. An admin declared in Rust, checked by the compiler
The old admin relied on a macro and generated code. It has been removed. The new one is a typed builder, ModelAdmin, where each column is named using the entity's Column enum:
use runique::prelude::*;
fn blog() -> ModelAdmin<blog::Entity> {
use blog::Column::*;
ModelAdmin::new()
.list(|l| {
l.display([Title, Email, Website, Status])
.sortable([Title, Status])
.search([Title, Summary])
.filter([Status])
.action(Status, "Published", "Publish")
})
.form(|f| {
f.fields([Title, Email, Website, Summary, Content, Status, ViewCount])
.readonly([ViewCount])
})
}
A misspelled column, or one renamed in the model, will no longer compile. There are no longer any strings to keep synchronized with the schema, and no generation step: the admin is integrated into the application builder with .with_admin(admin::config).
What comes with it: multi-value filters, filter pagination, bulk actions, change history, and pages that can be overridden block by block, validated at startup (an unknown variable in an override is rejected before the first visitor arrives).
3. Permissions with a single entry point
This is the core of 4.0. In an admin, the real question isn't "who can get in", but "who can read or modify which column of which table".
Runique 4.0 answers this with ranks and account-level permissions:
| Rank | Role |
|---|---|
| Superuser | the developer, until delivery |
| Director | the day-to-day site administrator |
| Admin | a moderator: they only have what the director checks for them, table by table and column by column |
A rank can only be managed downwards, never on oneself, and is never a form field.
The technical decision I'm most pleased with: all checks go through a single module, the gate (auth::gate). Every operation on protected data requires proof of the Authorized type, and only the gate knows how to create one: its constructor is not visible anywhere else. Writing without going through the gate does not compile. A missed permission check becomes a compilation error, not a vulnerability discovered in production.
Permissions act directly on the SELECT itself: an unauthorized column is never read from the database, rather than simply being hidden from the display.
4. Forms that write correctly
The form engine has been redesigned around one idea: what is validated is what gets written, in a single transaction.
use runique::prelude::*;
pub async fn create_blog(mut request: Request) -> AppResult<Response> {
let form: BlogForm = request.form("blog");
let mut form = match ValidationForm::try_form(form, &request).await {
Ok(validated) => validated.into_form(),
Err(form) => {
context_update!(request => { "blog_form" => &form });
return request.render("blog/blog.html");
}
};
match form.save(&request.engine.db).await {
Ok(_) => Ok(Redirect::to("/blog").into_response()),
Err(err) => {
// the database error is shown in the form, not as a 500
form.forms_mut().database_error(&err);
context_update!(request => { "blog_form" => &form });
request.render("blog/blog.html")
}
}
}
- Each form has a name (
request.form("blog")). Two forms on the same page no longer get mixed up: only the one that was submitted is validated. -
savewrites by itself, within a transaction. Uploaded files are moved between the database write and the commit: a rejected write leaves no orphaned files, and a file that cannot be moved rolls back the write. -
MediaField::image(),audio(), andvideo()check the file's content, not just its extension: a text file renamed to.mp4is rejected. - Upload limits are consistent from end to end: a field never accepts more than the server reads, and a request declared too large is rejected based on its headers, before a single byte is read.
5. Security, reviewed point by point
Version 4.0 fixes issues found while auditing the framework, and I'd rather list them than keep quiet about them. A few examples:
- Session contents stored in the database are encrypted (XChaCha20-Poly1305, key derived from
SECRET_KEY); - A form submitted from another site is rejected before reaching the handler (
Sec-Fetch-Site), in addition to the CSRF token; - "Forgot password" responds in the same amount of time regardless of whether an account exists;
- "Active" files (
.html,.svg,.js...) are never accepted by a file field; - An upload error message no longer reveals the server's directory structure.
The complete list, with the severity of each issue, is available in SECURITY.md.
6. What this breaks
A lot, honestly: this is a major release. The old admin is gone, the form API changes (try_new becomes try_form, save replaces the before_save/after_save hooks), and the session table gains a column. Everything is detailed, step by step, in the 3.x → 4.0 migration guide.
7. What's next
Version 4.1 will focus on makemigrations: generating all schema operations without a manual step (table deletions, actual ALTER operations when changing a type, relation tables, renames). The roadmap is public: ROADMAP.md.
Try it
[dependencies]
runique = { version = "4.0", features = ["postgres", "admin"] }
cargo install runique --features postgres
runique new my_project
- Documentation (FR/EN): Documentation
- Source code: Github
If you're coming from Django, I'd be curious to know what you're still missing. And if you're more into Rust, the Authorized proof system is something I'm particularly interested in hearing feedback about. All feedback is welcome.
Top comments (0)