Releases and security

4.0.0 - unreleased

Security

  • "Forgot password" answered faster for an unknown address: the page wrote the reset token, and waited for it, only when the address belonged to an account, so its response time told whether an account existed. The account's lookup, the token and the email now all run in the background: the page answers the same, in the same time, for every address.

  • Admin — refusal on a superuser's account: the message shown ("Only a superuser can change a superuser's account") told a staff member that the targeted account was a superuser. It is now generic ("Action not allowed", 9 languages); the rule itself is unchanged.

  • CSRF token sent to other sites by csrf.js: the script wraps fetch to add X-CSRF-Token to POST/PUT/PATCH/DELETE requests, without looking at where they go. A POST fetch to another domain, made from a page that loads the script (third-party script, project code), carried the session's token to that domain, and another site's response could replace the page's token. The token is now added, and its rotation accepted, only for requests to the page's own origin. Restart the application after updating: the static file changes.

  • extend!{} of the accounts table put password and is_active in its forms: the generated form of an extend! of eihwaz_users (a profile, say) took both from the account's own columns. An admin could pick a user's password at creation or edit — and know it — and deactivate any account, their own included, past the account rules. Neither is a form field any more, like the rank flags: a new account gets a random password nobody knows (its owner sets theirs from the email link) and starts inactive until its owner activates it; an edit leaves both untouched, whatever the request carries. Built into the typed ModelAdmin forms — no generation step.

  • Debug error page could show a file outside templates/: on a template rendering error, the debug page read and displayed templates/{name} without checking the name. A template picked from request data (a slug) made ../../.env show the project's secrets. Only a plain relative path under templates/ is read now; .. and absolute paths are refused. The page only exists with DEBUG=true, so production deployments without it were not exposed.

  • Form global errors rendered unescaped: a form's global errors (a clean() error, a database error quoting the submitted value) went into the page through a format! behind the is_safe() form filter, so neither was escaped (XSS). They are now rendered by the form_errors.html template, under Tera's autoescape.

  • Form sent from another site refused before the handler: a handler reading its body with an Axum extractor (Form<T>, Multipart) doesn't check the CSRF token, and the middleware let any form without an X-CSRF-Token header through. It now refuses (403) a form whose browser announces Sec-Fetch-Site: cross-site or same-site, always on. It doesn't replace the token: this header is only sent over HTTPS and on localhost. Possibly breaking for an HTML form posted from a subdomain or by a third party: see csrf_exempt() and the CSRF docs.

  • session_data stored in clear: the content of signed-in sessions, CSRF token included, could be read in eihwaz_sessions (a backup, an SQL injection). It is now encrypted (XChaCha20-Poly1305, key derived from SECRET_KEY, tied to the row). A row in clear, altered or copied is refused, and changing SECRET_KEY signs users out at restart. Breaking: RuniqueSessionStore::new(db, &secret_key); see the migration guide, which empties eihwaz_sessions.

  • A reset left the other sessions of a custom store: with with_session_store(...), a cookie stolen before the reset stayed valid, since that store can't be searched by user. The eihwaz_users.session_version column, increased in the reset's transaction and by close_user_sessions, closes every session opened before, whatever the store, with no extra query. Breaking: a column to add, see the migration guide.

  • add_js scripts came without an SRI hash or a nonce: unlike {% static %}, the browser didn't check their content. The tag now carries integrity and crossorigin when the file is among the project's static files, and the request's CSP nonce for a form obtained through request.form("name"). As with {% static %}, restart the application after changing one of these files: its hash is computed at boot.

  • Account created without its activation (project generated by runique new): signing up wrote the account, then its activation separately; an error in between left an account that could not be activated. Both now go in one transaction.

  • Uploads could be validated before they were fully written: parse_multipart never flushed the staged file, so validation could read the size or content of a partial file, and an upload over a field's limit could pass. A failed write could also go unseen and leave a truncated file. The file is now flushed before the form sees it, and a write failure refuses the request.

Breaking changes

  • AdminTemplate::with_base and with_bulk_edit removed: the first changed nothing (every admin page extends admin_base.html itself), the second named a page no route served (the old admin's bulk edit, its template and script removed with it). A layout is replaced through its blocks (global_block), a page through with_* or ModelTemplates::page.
  • utils::constante::admin_key::admin_context removed: the old admin's template context keys, no longer read by any page. A page's keys are now the fields of its context (ListContext, DetailContext…), every page rendered by a test.
  • Production refuses a password reset without a mailer: with_password_reset() without with_mailer() / with_mailer_from_env() (with its SMTP_* set) stops the boot, like a missing public URL: the page could never send its link. Debug still boots, and the page then answers every address with the same "not available right now" notice. A reset token is no longer made when no email can carry it.
  • AdminConfig.reset_route replaced by reset_link: Option<ResetLinkConfig> (the reset page's route and the link's lifetime), set by the builder from with_password_reset(). None without it: the site serves no reset page, so the admin sends no link instead of one leading nowhere. Reset links are made by one function, auth::password::send_reset_link, for the site's page and the admin alike.
  • decimal declares its precision: price: decimal [max_digits: 10, decimal_places: 2] (max_digits counts every digit, decimal_places those after the point: 99999999.99 at most here). A decimal without both is a compile error: left out, MySQL/MariaDB created DECIMAL(10, 0) and rounded every value to an integer (see Fixed). Limits are MySQL/MariaDB's, the lowest of the three engines: 65 digits, 30 after the point.
  • belongs_to declares its ON DELETE action: belongs_to: Course via course_id [cascade]. Without an action, a compile error, like Django's on_delete. [no_action] keeps the previous behaviour, with no schema change.
  • New admin: typed ModelAdmin builder, ranks and per-account rights: admin!{}, the code generated in src/admins/ and runique start are gone. Declared in src/admin.rs, wired with .with_admin(admin::config), cargo run is enough. Access rests on three account ranks (is_superuser, is_dirigeant, is_admin) replacing is_staff on the account; is_staff moves to groups. Every admin operation goes through a single decision gate. The columns of eihwaz_users, eihwaz_groupes, eihwaz_groupes_droits and eihwaz_history change: see the migration guide.
  • The admin list's context: has_prev, has_next, prev_page, next_page and page_size_options are replaced by list_pager, filter_pagers and filter_size_pager; filter_values becomes a list of { value, active, qs } and active_filters maps each column to a list of values. An overridden list template must be adapted: see the context keys.
  • RuniqueError::Template carries its line: Template(String) becomes Template { message, line }. RuniqueError::template(&tera_error) fills both.
  • EnvironmentInfo.app_version replaced by runique_version: the old field actually gave Runique's version. The application's version reaches the debug page's context (app_name, app_version).

Form engine

Breaking changes

  • request.form() takes a name, and ValidationForm::try_new becomes try_form: let form: MyForm = request.form("name"); then ValidationForm::try_form(form, &request). The name is rendered as a hidden runique_form field (first in the form, before CSRF). The form is only filled and validated when the request sends that same name back: on a page with several forms, only the one submitted gets the data, the CSRF check and its errors; the others stay blank, is_submitted() false, and two forms may have same-named fields. A hand-written form must carry the runique_form field itself.

  • FileField::image() replaced by MediaField::image(): MediaField gathers the files whose content is checked (see Added). FileField keeps document() and any(). The DSL's image type follows, with no change in model!{}.

  • Runique no longer deletes the file an upload replaces: deleting it made going back impossible if a later write failed. Cleaning up the old file is now up to the project.

  • Field HTML ids carry the form's name: id="id_{form}-{field}" (and for on labels, container_{form}-{field}), so two forms with a same-named field no longer produce duplicate ids. CSS or JS targeting #id_{field} must be updated.

  • The CSRF hidden field no longer has an id: id_csrf_token was duplicated on every page with two forms. csrf.js reads the field by its name, unchanged.

  • A field whose rendering fails is shown in place (Render error: …) instead of silently disappearing from field-by-field rendering.

  • RuniqueForm::build and build_with_data removed: a form built by hand skipped what request.form("name") adds (honeypot, CSRF enforcement on a write, submitted data). A handler gets its forms from request.form::<T>("name"), and empties one with form.clear(). Tests without a request use runique::forms::field::build_form_for_test::<T>(tera, csrf_token, "name") (test-utils feature).

  • RuniqueForm split in two: FormAccess (fields and accessors) and RuniqueForm (business logic): #[form(schema = …)] now generates the impl FormAccess itself, so impl_form_access!(model) is gone from user code — impl RuniqueForm for MyForm {} holds only clean, customize, hooks… A hand-written form moves register_fields and impl_form_access!() into its own impl FormAccess for MyForm { … }. FormAccess is in the prelude; code importing RuniqueForm by path and calling get_form() / register_fields() on a concrete form imports FormAccess too. impl_form_access!(field) (accessors on a field not named form) removed: unused.

  • save writes by itself, on the validated form; on_save, before_save, after_save, save_with and SaveContext removed: on_save did nothing by default, so save wrote nothing until the form implemented it. save(&db) and delete(&db) are now methods of the validated form (ValidationForm, returned by try_form), for forms tied to an entity: Runique walks the fields and writes the row, through SeaORM outside the admin (the model's ActiveModelBehavior included), through the gate inside it (rights, owner, history). The row comes from the URL: {<name>_<key>} (the name given to request.form), else {<key>}, else a create. Both give the form back: Ok(form) once written, Err(form) with the error set on it. A form without an entity has no save: its handler writes after try_form. Migration: drop on_save and the hooks; business logic goes into clean_field / clean / register_dynamic_fields, outside effects (email) and writes to other tables into the handler after a successful save. Forms::mark_validated (test-utils) removed.

  • #[form(schema = …)]: the model = … attribute is removed, FormEntity is always generated: the form is tied to path::Entity (schema = blog → blog::Entity), which gives search!(@MyForm => …) and MyForm::objects with nothing to declare. schema = must therefore name a model module (model!{}, extend!{}) whose entity has a ModelMeta. FormEntity::Entity requires ModelMeta (instead of EntityTrait); model!{} and extend!{} also generate FormEntity for their form.

  • reverse_with_parameters returns Result<String, UrlError> (was Option<String>) and encodes every value: a /, ? or # in a value no longer changes the route reached. A missing parameter (which left {id} in the URL) or an unknown one is now an error instead of being silent. {% link %} goes through the same code: a template passing a parameter its route does not declare now fails to render.

  • Admin .extra_routes() removed (with ExtraRoute): a custom admin route is declared in urlpatterns! with the admin keyword (admin "/path" => view!{ … }, name = …, for_model = Entity, overrides = AdminView::…). It keeps its own path, with no admin prefix, and names its model by type instead of a string key.

  • urlpatterns! returns a Routes (instead of an axum::Router): the declaration keeps its metadata until build(), which lets it hold admin routes. A routes() -> Router function becomes routes() -> Routes; the builder's .routes() takes a Routes or a Router, and Routes has merge plus the RouterExt methods (login_required, rate_limit…).

  • admin_page_context: an admin route can render on the admin's layout (stylesheets, menu with the rights applied, top bar), without granting it any right.

  • admin routes in urlpatterns!: admin "/path" => view!{ … }, name = …, for_model = Entity (overrides = AdminView::Detail replaces the generated detail page; Standalone by default). The route keeps its own path behind its own boundary: an anonymous visitor is sent to / (to the admin login only when the previous page was an admin page), an account without admin access to / with a notice, its session left open.

  • Sentinel and GuardRules removed (forms::prisme::{sentinel, rules}, evaluate_rules): access control by rank or group name (staff, superuser, roles) goes through the admin boundary and rights (admin routes in urlpatterns!); login_required remains available through RouterExt::login_required. Outside the admin, an entry check belongs in the handler. The Prisme pipeline is now Aegis → CSRF.

  • Form errors have their own stylesheet: form_errors.html loads css/forms.css from Runique's static files when a form shows an error (selectors in :where(), colours overridable through --runique-form-error-color / --runique-form-error-focus).

  • cleaned_string, cleaned_i32… cleaned_bytes removed, replaced by cleaned::<T>(): form.cleaned::<i32>("age"), form.cleaned::<Decimal>("price"), form.cleaned::<String>("name"). Each helper re-read the field's text on its own, with its own rules, and nothing tied the type asked for to the column: a price read as f64 lost its exactness without a word. cleaned::<T> reads the text the way T's column reads it, through the one reading the write makes too, so i16 refuses 40000 and Decimal keeps every digit. Same whitelist as before (POST > path > query, declared fields only); a password still comes back as typed. cleaned_enum and cleaned_enums stay. Replace .cleaned_X(name) by .cleaned::<T>(name) with the type the helper returned.

  • FormField::to_value() is a required method: a field type written outside Runique must say how its value is stored.

  • required on a checkbox now means "must be checked": it used to mean the column is NOT NULL, which a box always is (it answers true or false), so a terms box had to be enforced in clean(). cgu: bool [required] / BooleanField::new("cgu").required() now refuse an unchecked box (forms.must_be_checked, 9 languages, traced), like Django's form BooleanField; required with default: true is a compile error, a box checked in advance not being consent. To migrate: remove required from every bool that only meant NOT NULL (active: bool [required] → active: bool) — it would otherwise make the box impossible to leave unchecked, in the admin too. Schema-built forms follow: a NOT NULL bool column gives an optional box, only a declared required makes it compulsory.

  • email and phone fields are now bounded in the form (254 and 20 characters), as their columns already were: a customize that replaces one without a max_length, or with a larger one, now panics at startup (loosened max_length).

Added

  • Per-page size selector on the admin lists: a selector (10 / 25 / 50 / 100) at the top right, kept across sort, search and filters. Only those values are accepted from the URL; a free size is ignored.
  • Paged filter panel: each filter group has its arrows, and a single selector (10 / 25 / 50 / 100), under the panel's title, sets the number of values of all groups. .filter_size(n) sets the default on AdminStaging and in .list(|l| …), on the model of page_size / per_page. Replaces the fixed cap of 100 values, past which the next ones weren't offered.
  • Several values per filter, and deselecting: filter_x=a&filter_x=b keeps the rows holding a or b; several columns combine. Clicking an active value turns it off alone. The values stay bound by the gate's scope, and a column accepts at most 100 from the URL.
  • Debug page: the application's version (read from the project's Cargo.toml, in debug only) and Runique's each have their card; footer translated in the 9 languages and a readable date. Error pages and debug page redrawn on the admin's palette.
  • An account's rank and groups shown in the admin: on the accounts' list and detail, read-only, for every account with access to that list. Changing either still goes through the gate. Two more queries per page, however many rows it has.
  • request.db_atomic() and save/delete inside a transaction: save and delete take the connection or a transaction, to tie the form's write to the handler's own; nothing is real before commit(). A form on an admin route refuses a transaction: the gate decides on what it reads through the connection.
  • MediaField::audio() and MediaField::video(), DSL types audio and video: same rules as MediaField::image(), with the file's signature checked on top of its extension.
  • csrf.js loaded by itself: request.render() adds a single tag per page as soon as it holds a csrf_token field, however many forms it has, and none if the page already loads the script.

Fixed

  • A file and the row of the same save could disagree: the file is now moved inside the transaction, between the write and the commit. A refused write no longer leaves a published file, nor a missing file behind a written row. One case remains: a successful move then a lost commit leaves an orphan file, never a row without its file.
  • A forged boolean was read as false: a checkbox field accepted any value, and anything other than true/1/on was stored as false — a request sending is_active=banana deactivated the account without anyone sending false. A checkbox now only accepts what it sends: its own value ("true" by default, .checked_value("on") for a hand-written template whose box sends something else) or nothing (unchecked, which fill() still turns into false). Anything else is a form error under the field, and is traced (forms.validate, field name only — never the value received). Below the field, where a column or the code model!{} generates for the admin reads a boolean, six spellings are accepted in any case (true/1/on, false/0/off) and anything else is an error (one shared parse_bool).
  • A form whose value its column can't store is invalid: a field given its column (a #[form(schema = …)] form or the admin) reads its value into that column's type during is_valid(); one that doesn't fit gets an error under the field instead of a database error at write time. cleaned::<T> reads through the field's column when it has one, else through the type T.

Fixed

  • Forms sent despite preventDefault(): csrf.js intercepted every submission and always sent the form again. A form sent by fetch therefore left twice (the second request could answer "duplicate" after the first had saved), and a check or a confirmation stopping the submission in the browser had no effect. The script now listens on window, after the others, and leaves a stopped form alone. Restart the application after upgrading: the static file changes.

  • Accounts created in the admin had no dates: created_at and updated_at were only written by the CLI or a project's own form. The accounts' entity and an extend! of eihwaz_users now fill them on save, keeping a date the writer sets.

  • Fields of an admin detail page in random order: the page followed the template's map, whose order isn't kept. It now shows the list's columns in their order, then the form's, through the new detail_columns key, with dates and times last. An automatic list also shows them last; a declared display keeps its order.

  • session_version on an account's admin page: the counter that revokes sessions showed among the fields when the account resource declares no .form(). It is no longer read there.

  • Admin dates shown in raw ISO: lists and detail pages showed 2026-11-06T14:58:32.907849. Date-times and times now read like the history page, from a single function (2026-11-06 14:58:32), through the new display_time filter.

  • Admin pages without CSS after upgrading Runique: the statics' ?v= only followed the project's files, not Runique's, served immutable for a year. A CDN or browser therefore kept the old file under the same address, and the browser blocked it because it no longer matched its SRI hash. The token now covers both folders.

  • The failing line was never highlighted on the debug page: the number was looked for by a regex (line N) in the error's text, which Tera 2 no longer writes that way. It is read from the error's span.

  • Wrong Rust version on the debug page: rustc --version ran at runtime, so on the machine running the binary (often without rustc). The compiler is now recorded at build time.

  • The list's size selector and search kept the old URL: hx-get="" made the fields get added to the current URL. Changing the size or searching didn't go back to the first page, and the URL grew with each keystroke. Both now target the list's address.

  • decimal rounded to an integer on MySQL/MariaDB: makemigrations created a bare DECIMAL, which MySQL/MariaDB read as DECIMAL(10, 0): 9.99 was stored as 10, with no error. The column is now created with its declared precision; on an existing table, the next makemigrations emits the change, flagged as destructive (--force) since a value with more digits than the new precision would be rounded or refused. Values already rounded can't be recovered.

  • Entity, schema() and migration agree on every column: the entity model!{} generates now declares each column's type (#[sea_orm(column_type = …)]) from the DSL instead of SeaORM guessing it from the Rust type, so a text's length and a decimal's precision reach the entity; schema() declared char, timestamp, json_binary and interval columns the migration creates as text, date-time and JSON. One mapping now feeds all three, checked for every DSL type.

  • The admin's reset links ignored token_ttl: they always lasted one hour, whatever with_password_reset(|pr| pr.token_ttl(…)) set, and every reset email, the site's included, announced "1 hour". The admin now follows the setting, and the emails show the link's real lifetime in minutes (9 languages).

  • Admin boot check on a model with an owner: it required the owner column (meta: { owner }) in fields, while the gate sets it to the creating account whatever the form sends. A model with an owner can now declare its form without it.

  • Enum columns on MySQL/MariaDB: makemigrations wrote an enum column as ColumnType::Enum on every engine, which MySQL/MariaDB create as a native ENUM(...). The migrations that later add or rename a value change nothing there, so the new value was refused (1265 Data truncated, or stored as an empty string outside strict mode). The column is now a Postgres enum type on Postgres and a plain string elsewhere, as the entity already reads it; migrations written before keep reading back. On an existing MySQL/MariaDB table, turn the column into a string once (ALTER TABLE … MODIFY COLUMN … VARCHAR(255) NOT NULL, with its default).

  • search! panicked outside Postgres: ilike, icontains, iexact and not_ilike went through ILIKE, which sea-query only renders for Postgres; on MySQL/MariaDB and SQLite the query panicked. They are now LOWER(column) LIKE, like search_cond!, on every engine.

  • The CLI ignored DATABASE_URL from the environment: runique migration up and create-superuser loaded .env over the environment, so a DATABASE_URL given by a CI, a container or the command line was replaced by the .env one, and the command ran on that database. The environment now wins, as everywhere else; the language set in .env (LANG) still wins over the system's.


← Newer Older →