Skip to content

The right to post is a value, not a flag

Level: 301 · deep dive

One line: Rust has no authentication framework to hand you — what it has is a way to make "this request is authenticated" a type instead of a boolean, so that forgetting the check stops being possible and the check you still have to get right shrinks from every call site down to one constructor.

Ask how Rust does authentication and the honest first answer is deflating: it does not. There is no django.contrib.auth, no Devise, no Spring Security. You assemble it — a hashing crate, a session or token crate, a database, and your web framework's middleware — and every one of those pieces exists in three competing versions. Compared with a batteries-included framework this is a real cost, and it is worth saying before the good part.

The good part is what you can do that those frameworks cannot. In every one of them, "the user is logged in" ends up as a fact stored beside the thing it is supposed to guard — a boolean on a session object, a key in a dictionary, a decorator someone remembered to write. The guard and the guarded thing are two separate items, and keeping them together is a convention. In Rust you can make them one item, and then the compiler is checking your access control for you as a side effect of checking your types.

This page builds the smallest honest version of that, on the smallest honest requirement a review site has: sign up first, then sign in, then post — once.


The version that already compiles

Here is the design almost everyone writes first. A account carries two facts, and a post function puts a review in the box:

pub struct Account {
    pub id: AccountId,
    pub signed_up: bool,
    pub has_posted: bool,
}

pub fn post(account: &mut Account, review: Review, posted: &mut Vec<Review>) {
    account.has_posted = true;
    posted.push(review);
}

Read that signature as a contract and see what it promises: nothing. It will accept any account you hand it, in any state. The rule "signed up, and has not already posted" is not written down anywhere the compiler can see it — it lives in the head of whoever calls post, and it has to live there correctly at every call site, forever, including the ones added next year by someone who has never read this file.

So the endpoint written first checks both facts and is perfectly correct. And then a "let me fix my review" endpoint gets added, remembers signed_up, forgets has_posted, and compiles exactly as well:

Verified output of one_account_one_review.rs — regenerated by tools/run_examples.py, never hand-typed.

Design A — the rule lives in whoever calls `post`:
  AccountId(1) accepted     (checked endpoint)
  AccountId(2) accepted     (checked endpoint)
  AccountId(1) accepted     (resubmit endpoint)
  AccountId(3) refused      (not signed up)
  -> 3 reviews in the box from 2 eligible accounts, 19 points post
  -> design A: MORE REVIEWS THAN ACCOUNTS

Design B — the right to post is a value, and posting spends it:
  AccountId(1) signed in
      review accepted, receipt serial 1
  AccountId(2) signed in
      review accepted, receipt serial 2
  AccountId(3) refused at sign-in
  AccountId(1) refused at sign-in
  AccountId(1) signed in
      review accepted, receipt serial 3
  -> 3 reviews in the box from 2 eligible accounts, 21 points post
  -> design B: MORE REVIEWS THAN ACCOUNTS

Both boxes hold three reviews. Design A can be fixed at every call
site that forgot a check; design B has exactly one function to fix.

Two eligible accounts, three reviews. Note that the type system was never even slightly involved: bool is the right type for both fields, post takes exactly the arguments it needs, and there is no version of this design that a reviewer could call ill-typed. It is a correct program that implements the wrong rule.

Make the permission a thing you can hold

Now the same flow, with one structural change: instead of asking about the account, the account is handed a token, and the token is the argument.

/// Proof that this account is on the roll and got their password right.
///
/// The field is private and this module offers exactly one constructor, so
/// nobody outside can build an `Eligible` without going through the check.
/// That, and not the name, is the whole of the guarantee.
pub struct Eligible {
    id: AccountId,
}

pub fn sign_in(id: AccountId, roll: &[AccountId], password_ok: bool) -> Option<Eligible> {
    if password_ok && roll.contains(&id) {
        Some(Eligible { id })
    } else {
        None
    }
}

The load-bearing word in that comment is private. Eligible is not safe because of its name; it is safe because its field is private and sign_in is the only function in the module that constructs one, which is the same privacy-is-per-module argument that makes a newtype worth anything. Outside this module there is no syntax for conjuring an Eligible — you can only be given one, and you are only given one after the check. A function that takes Eligible therefore cannot be reached by an unauthenticated caller, and "did you remember to check?" stops being a question anyone can answer wrongly.

That much makes the permission unforgeable. The second half makes it unrepeatable:

impl Eligible {
    /// `self`, not `&self`. Posting a review consumes the right to post one.
    pub fn post(self, review: Review, posted: &mut Vec<Review>) -> Receipt {
        posted.push(review);
        Receipt { serial: posted.len() }
    }
}

self by value, not &self by reference. Posting a review moves the token into post, where it is dropped — so the caller no longer has it, and a moved value cannot be used again. One account, one review, expressed as ownership. Try to spend it twice and the program does not run at all:

error[E0382]: use of moved value: `token`
    |
151 |             Some(token) => {
    |                  ----- move occurs because `token` has type `Eligible`,
    |                        which does not implement the `Copy` trait
153 |                 let receipt = token.post(Review([5, 2, 0]), &mut posted);
    |                                     ---- `token` moved due to this method call
...
157 |                 token.post(Review([0, 0, 5]), &mut posted);
    |                 ^^^^^ value used here after move
    |
note: `Eligible::post` takes ownership of the receiver `self`, which moves `token`
    |
124 |         pub fn post(self, review: Review, posted: &mut Vec<Review>) -> Receipt {
    |                     ^^^^

The compiler read a four-word design decision — post takes self — and derived the whole rule from it, down to pointing at the self that caused it.

And it still let a review through

Look at the second half of the run above. Design B is holding three reviews too.

This is the part a page about typestate usually leaves out, so it is worth being blunt: move semantics govern one value's lifetime; they say nothing about how many values a constructor hands out. No token was spent twice. Ada simply signed in twice and was given a second token, and the second token was as genuine as the first.

So the bug is not fixed — but notice what changed about its shape. In design A the rule was enforced at every call site, so the hole could be in any of them, and finding it meant reading all of them. In design B there is exactly one function that can mint permission, so there is exactly one place the bug can be, and sign_in is four lines long. That is the actual return on the whole exercise, and it is worth more than the compile error: not that the compiler catches the bug, but that it collapses the surface where the bug can hide from "everywhere" to "here." Closing that last hole is the kata below.

The other honest limit is the one that matters if you ship this. All of the above holds within one process, along one call path. A real server handles each request separately and mints the token afresh from a cookie every time, so nothing in the language remembers that this account posted four minutes ago in another request on another thread. The durable guard is a UNIQUE constraint on (product_id, account_id), or an atomic compare-and-swap in the store — the database is what actually enforces one-account-one-review, and it has to, because it is the only participant that survives a restart. Typestate makes the in-memory path unable to express the bug; it does not make the storage layer optional.

Where the identity has to stop

One more line from that example earns its place, because it is the requirement an anonymous review site has that an ordinary app does not:

/// What the account walks away with. Note what is *not* in it: no `AccountId`.
#[derive(Debug)]
pub struct Receipt {
    pub serial: usize,
}

Authentication and review anonymity pull in opposite directions. You must know who signed up, or you cannot enforce one review each. You must not be able to link a stored review back to a person, if reviews are meant to be anonymous. So the identity has to travel exactly as far as the door and stop — and the ownership design gives you a natural place to stop it. The AccountId lives inside Eligible; post consumes Eligible and drops it; the Review that goes into the box was never given the id in the first place, and the Receipt that comes back carries a serial rather than a name.

Get this wrong in the obvious way — store the AccountId alongside the review "for audit" — and you have built a system that can tell anyone with database access what each named person wrote. The type that stops that is not clever; it is just a struct with one field, chosen deliberately.

If you are coming from another language

  • Python@login_required is the same idea checked at runtime: a decorator wraps the view and raises before the body runs. What it cannot do is make the absence of the decorator visible; a view written without it is a public view, it looks identical in review, and the only thing that ever tells you is an audit or an incident. Rust moves the requirement into the signature, so the missing check is a compile error rather than a missing line.
  • ABAPAUTHORITY-CHECK OBJECT … followed by IF sy-subrc <> 0 is the closest cousin, and it has the classic sy-subrc failure mode: the check and its consequence are two separate statements, and the second one is optional. A program that runs AUTHORITY-CHECK and never reads sy-subrc passes a code review beautifully — the check is right there on the screen. Returning Option<Eligible> removes that gap, because there is no path to the protected value that skips the failure branch. The move-on-post has no ABAP counterpart at all: you would enforce one-review with an update and a COMMIT WORK, and trust that nothing calls the function twice.

Both bridges land on the same sentence. The convention you used to rely on — remember the decorator, remember to read sy-subrc — becomes something the compiler is now holding for you, and what you get back is the ability to stop thinking about it.

What this looks like in a real server

The pattern above is the whole idea; the crates are the boring part. For a STAR voting app, a typical assembly is a password hash (argon2, using Argon2id), a session store or a signed token (tower-sessions, or jsonwebtoken if you want them stateless), a database (sqlx), and a web framework. If you would rather not own any of it, openidconnect lets you delegate the whole sign-up-and-sign-in problem to an identity provider — which is what BetterVoting itself does, with Keycloak.

The framework is where the pattern reappears with a different name. In axum, implementing FromRequestParts for your Eligible type turns it into an extractor: a handler written async fn post_review(account: Eligible, …) will not be entered at all unless the extractor succeeded, so the check happens at the HTTP boundary and the handler body is unreachable without it. rocket calls the same thing a request guard, which is the better name. Either way the handler's argument list is the access-control policy, and it is checked at compile time — the extractor for a type you did not implement simply does not exist.

(None of those crates are compiled by this repo's CI — the examples here are standard library only, so that the page's claims stay ones a program on your machine actually printed.)

Practice

Close the sign-in hole. Design B stopped the second post and let the second sign_in through. Change sign_in so that an account can be handed at most one token per product, and prove it: sign the same account in twice and have the second attempt refused.

Then look hard at what your fix does to an account whose browser crashes after signing in but before submitting, and decide whether you have traded a double review for a lost one.

Solution

one_account_one_review_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.

//! Kata solution — the hole the compiler could not see.
//!
//! In the lesson's design B, `post` consumes the token, so no token is ever
//! spent twice. Ada still posted twice, because she signed in twice and was
//! handed a *second* token. Move semantics govern one value's lifetime; they
//! have nothing to say about how many values a constructor hands out.
//!
//! The fix is in `sign_in`, and it is bookkeeping rather than types: the roll
//! holds one unspent entitlement per account, and signing in takes it. The last
//! two lines of output are the price of doing it this way.

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct AccountId(u32);

/// A 0-5 score for each of three products.
#[derive(Debug, Clone, Copy)]
struct Review([u8; 3]);

impl Review {
    fn total(&self) -> u32 {
        self.0.iter().map(|&s| s as u32).sum()
    }
}

mod moved_token {
    use super::{Review, AccountId};

    /// The register of who may still post. One entry per account, removed on
    /// sign-in — so the roll shrinks as the batch runs.
    pub struct Roll {
        unspent: Vec<AccountId>,
    }

    impl Roll {
        pub fn new(ids: &[AccountId]) -> Roll {
            Roll { unspent: ids.to_vec() }
        }

        pub fn remaining(&self) -> usize {
            self.unspent.len()
        }
    }

    pub struct Eligible {
        id: AccountId,
    }

    /// `&mut Roll`, not `&[AccountId]`. Signing in now *changes* the roll, and
    /// the `?` on `position` is the whole of the second-token guard.
    pub fn sign_in(id: AccountId, roll: &mut Roll, password_ok: bool) -> Option<Eligible> {
        if !password_ok {
            return None;
        }
        let pos = roll.unspent.iter().position(|&v| v == id)?;
        roll.unspent.swap_remove(pos);
        Some(Eligible { id })
    }

    #[derive(Debug)]
    pub struct Receipt {
        pub serial: usize,
    }

    impl Eligible {
        pub fn id(&self) -> AccountId {
            self.id
        }

        pub fn post(self, review: Review, posted: &mut Vec<Review>) -> Receipt {
            posted.push(review);
            Receipt { serial: posted.len() }
        }
    }
}

fn main() {
    use moved_token::{Roll, sign_in};

    let mut roll = Roll::new(&[AccountId(1), AccountId(2)]);
    let mut posted: Vec<Review> = Vec::new();

    println!("Roll opens with {} unspent entitlements.\n", roll.remaining());

    // Ada posts.
    if let Some(token) = sign_in(AccountId(1), &mut roll, true) {
        println!("  {:?} signed in", token.id());
        let receipt = token.post(Review([5, 2, 0]), &mut posted);
        println!("      review accepted, receipt serial {}", receipt.serial);
    }

    // Ada tries again with the right password. This is the line the lesson's
    // design B let through.
    match sign_in(AccountId(1), &mut roll, true) {
        Some(_) => println!("  AccountId(1) signed in a second time"),
        None => println!("  AccountId(1) refused  (entitlement already spent)"),
    }

    // Ben signs in, then his connection dies before he submits.
    if let Some(token) = sign_in(AccountId(2), &mut roll, true) {
        println!("  {:?} signed in", token.id());
        drop(token); // the browser closed; no review was ever post
        println!("      token dropped without posting");
    }

    // Ben comes back.
    match sign_in(AccountId(2), &mut roll, true) {
        Some(_) => println!("  AccountId(2) signed in again"),
        None => println!("  AccountId(2) refused  (entitlement already spent)"),
    }

    let points: u32 = posted.iter().map(Review::total).sum();
    println!(
        "\n  -> reviews in the box: {} (2 eligible accounts, {points} points post)",
        posted.len()
    );
    println!("  -> entitlements left on the roll: {}", roll.remaining());
    println!("  -> nobody posted twice, and Ben did not post at all");
}

Verified output of one_account_one_review_kata.rs — regenerated by tools/run_examples.py, never hand-typed.

Roll opens with 2 unspent entitlements.

  AccountId(1) signed in
      review accepted, receipt serial 1
  AccountId(1) refused  (entitlement already spent)
  AccountId(2) signed in
      token dropped without posting
  AccountId(2) refused  (entitlement already spent)

  -> reviews in the box: 1 (2 eligible accounts, 7 points post)
  -> entitlements left on the roll: 0
  -> nobody posted twice, and Ben did not post at all

The guard is position(…)? — the roll holds one unspent entitlement per account and signing in removes it, so the second lookup finds nothing and the ? returns None. Note that this fix is bookkeeping, not types: there is no way to express "at most one of these may ever exist" in Rust's type system, so the counting has to be somewhere, and the honest place is the roll.

And it costs exactly what the question warned it would. Ben signed in, his connection died, his token was dropped without a review — and his entitlement is gone with it. One account, no review, no error message. Both real systems' answers to this are worth knowing: return the entitlement when the token is dropped without being spent (a Drop impl, which needs the roll to be shared — see lock poisoning for what shared-mutable costs), or keep the entitlement and let the review be replaced until the window closes, which is what most real review sites do and is a policy decision rather than a technical one.

Sources

The pattern on this page has a name, and knowing it is worth something when you go looking for the next one. Will Crichton's Type-Driven API Design in Rust ↗ calls Eligible a witness ↗ — a value whose existence is proof that a check was passed, so that a function demanding the proof cannot be reached without it, and forgetting the check becomes a type error rather than a security incident. Its worked example is an admin panel and the argument is the one made above: the comment // IMPORTANT: only call this when logged in as admin! and a parameter of type Admin say the same thing, and one of them is enforced.

The neighbouring chapter, guards ↗, is the variant this page does not use: a witness that also carries the capability it certifies, which is what MutexGuard is and why unlocking is not a method you can forget. A witness proves you were allowed in; a guard is what you were let in with. The spoken version of both is Crichton's Strange Loop 2021 talk, Type-Driven API Design in Rust ↗ (~41 min), annotated on the traits reading list.

Po polsku

Polski czytelnik ma tu przewagę, bo słownik wyborczy zna ze szkoły: wybory powszechne, równe, bezpośrednie i tajne. Ta strona bierze dwa z tych przymiotników — „równe” (jeden człowiek, jeden głos) i „tajne” — i próbuje zapisać je w typach, zamiast w komentarzu albo w regulaminie. Zaczyna jednak od uczciwego przyznania się do braku: Rust nie daje żadnego gotowego frameworka do uwierzytelniania (authentication) — nie ma tu odpowiednika django.contrib.auth ani Spring Security, wszystko składa się samemu z crate'ów. To realny koszt. W zamian dostaje się coś, czego tamte frameworki nie potrafią: fakt „ten wyborca jest uwierzytelniony” przestaje być boolem leżącym obok danych, które ma chronić, i staje się wartością, bez której chronionej funkcji po prostu nie da się wywołać.

Mechanizm ma dwie połowy i obie warto nazwać osobno. Pierwsza: Eligible to żeton (token) z prywatnym polem, a w module jest dokładnie jedna funkcja, która go tworzy — sign_in. Gwarancją nie jest nazwa struktury, tylko ta prywatność, i tu czeka pułapka na czytelnika wychowanego na Javie: w Ruscie prywatność działa na poziomie modułu, a nie klasy, więc sign_in może zbudować Eligible, choć nikt spoza modułu nie ma na to składni. Druga połowa to podpis pub fn post(self, …)self przez wartość, nie &self. Oddanie głosu przenosi własność żetonu do funkcji, gdzie zostaje wypuszczony, więc druga próba to error[E0382]: use of moved value, a nie wyjątek w czasie działania. „Jeden człowiek, jeden głos” wyrażone przez własność, z czterech słów w podpisie.

Najważniejsze zdanie na stronie mówi jednak, że to nie wystarczyło: w projekcie B w urnie i tak wylądowały trzy karty przy dwóch uprawnionych. Żaden żeton nie został wydany dwa razy — Ada po prostu zalogowała się drugi raz i dostała drugi, równie prawdziwy żeton. Semantyka przenoszenia rządzi życiem jednej wartości i nie mówi nic o tym, ile wartości wyprodukuje konstruktor. Zysk polega więc nie na tym, że kompilator łapie błąd, tylko na tym, że zwęża powierzchnię, na której błąd może się schować — z „każdego miejsca wywołania” do jednej czterolinijkowej funkcji. Warto też zapamiętać dwa ograniczenia, które strona stawia wprost: w systemie typów Rusta nie da się zapisać „takich wartości może kiedykolwiek istnieć co najwyżej jedna”, więc liczenie i tak musi gdzieś siedzieć (tutaj: w spisie wyborców), a trwałym strażnikiem zasady równości pozostaje ograniczenie UNIQUE na (product_id, account_id) w bazie — typy pilnują ścieżki w pamięci, w obrębie jednego procesu, a nie tego, co pamięta serwer po restarcie.

Na koniec tajność, czyli wymaganie, którego zwykła aplikacja nie ma. Uwierzytelnienie i tajne głosowanie ciągną w przeciwne strony: musisz wiedzieć, kto jest w spisie, i nie wolno ci móc powiązać karty w urnie z człowiekiem. Tożsamość ma więc dojechać dokładnie do drzwi i tam się zatrzymać — AccountId mieszka wewnątrz Eligible, post ten żeton pochłania, a Receipt wraca z numerem, nie z nazwiskiem. Klasyczny błąd to dopisanie AccountId do karty „na potrzeby audytu”: powstaje wtedy system, który każdemu z dostępem do bazy powie, jak głosował imiennie wskazany wyborca. Struktura, która temu zapobiega, ma jedno pole — cała robota polega na tym, żeby świadomie nie dodać drugiego.

Szukaj po polsku: tajność głosowania · spis wyborców · przeniesienie własności · rust typestate pattern · rust module privacy private field · axum FromRequestParts extractor