Dev.to WebDev 🛠 Dev 👁 0 📖 5 min read

Runique 4.0: a typed admin, column-level permissions, and forms that write correctly

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 r

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.
  • save writes 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(), and video() check the file's content, not just its extension: a text file renamed to .mp4 is 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

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.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.