Skip to content
Tutorials

Permission badges on rows

A post list needs more than titles. Maya should see an Edit button on her own posts, Ken should see one on posts shared with him as an editor, and a reader should just see the content.

Ask Rent for each row's permissions along with its data. That keeps the UI in step with the same rules your update handlers use.

Declare what editing means

For this example, Folder has owner and parent relationships. An owner can edit, and a child can inherit editing from its parent:

permissions Folder {
  editor = owner or parent.editor

  read           = anyone
  create         = parent.editor
  update, delete = editor
}

editor is our rule name. Your application might call it writer or use a different policy. The generated permission enum follows the names in your schema.

Load rows and flags

Add with_permissions() to the ordinary query:

use generated::folder::{Folder, FolderPermission};

let rows = client.folder()
    .with_permissions()
    .order_by(Folder::id)
    .limit(25)
    .all()
    .await?;

for row in rows {
    let can_edit = row.permissions.has(FolderPermission::Editor);
    println!("{}: show Edit = {can_edit}", row.entity.title);
}

Each result has an entity with the Folder data and permissions with its available rules. A readable folder stays in the list even if its editor flag is false. Your UI can use that flag to hide or disable the Edit action.

My folder: show Edit = true
Shared handbook: show Edit = true
Read-only folder: show Edit = false

Show only items the person can edit

When you want an “Editable by me” list, add a named permission filter:

let editable = client.folder()
    .can(FolderPermission::Editor)
    .with_permissions()
    .order_by(Folder::id)
    .all()
    .await?;

The row must satisfy both its normal read rule and the editor rule. Tenant scope and application policies still apply. Filters, pagination, and related loads use the same query-builder methods you already know.

Return only the flags

If the caller doesn't need the Folder data, use permissions():

let flags = client.folder()
    .permissions()
    .order_by(Folder::id)
    .all()
    .await?;

for granted in flags {
    println!("Can edit: {}", granted.has(FolderPermission::Editor));
}

These results describe the selected readable rows in query order. Choose the API for the response shape you need. It isn't a promise to fetch fewer database columns.

Let the eventual write decide again

Permission flags help present the interface. They don't reserve access: someone may revoke a share after you render the page. The update itself still checks permission.

To answer “why can Ken edit this?”, use permission explanations. For a single button outside a list, a resource check can be enough.

Complete example

Download this project, or copy the files below into an empty directory.

Replace RENT_REPOSITORY_URL in Cargo.toml with the URL supplied with your preview access. Use the matching Rent CLI release.

Use a separate local database for this example:

export DATABASE_URL='sqlite://lesson.db?mode=rwc'
rent generate
rent migrate dev --name initial
cargo run

Run the included test with cargo nextest run. It creates an isolated temporary database.

Cargo.toml
[package]
name = "rent-lesson-24-permissions"
version = "0.1.0"
edition = "2024"

[workspace]

[[bin]]
name = "rent-lesson-24-permissions"
path = "main.rs"

[dependencies]
rent = { git = "RENT_REPOSITORY_URL", tag = "v0.7.2", default-features = false, features = ["sqlite", "migrate", "extensions"] }
anyhow = "1.0.104"
tokio = { version = "1.53.1", features = ["full"] }
uuid = { version = "1.26.0", features = ["serde", "v4", "v7"] }
tempfile = "3.27.0"
rent.toml
schema = "schema.rsl"
generated = "generated"
migrations = "migrations"

[database]
dialect = "sqlite"
backends = ["sqlite"]
schema.rsl
datasource db {
  provider = "sqlite"
}

access {
  subjects = [User]
}

model User {
  id       String     @id
  name     String
  posts    Post[]
  comments Comment[]
}

model Post {
  id        String     @id
  title     String
  body      String
  author_id String
  author    User       @relation(fields: [author_id], references: [id])  @rent.creator
  comments  Comment[]
}

model Comment {
  id        String  @id
  body      String
  post_id   String
  author_id String
  post      Post    @relation(fields: [post_id], references: [id], onDelete: Cascade)
  author    User    @relation(fields: [author_id], references: [id])  @rent.creator
}

permissions User {
  read                   = anyone
  create, update, delete = nobody
}

permissions Post {
  editor                = author or [User]
  viewer                = editor or [User]
  read                  = viewer
  create                = anyone
  update                = editor
  delete, manage_grants = author
}

permissions Comment {
  read, create = post.viewer
  update       = author and post.viewer
  delete       = author and post.viewer or post.editor
}
main.rs
use anyhow::{Result, ensure};
use rent::policy::viewer::Viewer;
use rent::uuid::Uuid;
#[allow(dead_code, unused_imports)]
#[rustfmt::skip]
#[path = "generated/mod.rs"]
mod generated;
use generated::client::Client;
use generated::{post::PostPermission, user::User};

async fn example(database_url: &str) -> Result<()> {
    let root = Client::connect(database_url).await?;
    // Account provisioning is trusted application work, outside request handlers.
    let system = root.system();
    system
        .upsert_user(generated::user::unique::id)
        .id("maya")
        .name("Maya")
        .save()
        .await?;
    system
        .upsert_user(generated::user::unique::id)
        .id("ken")
        .name("Ken")
        .save()
        .await?;
    let maya = root.clone().with_viewer(Viewer::of(User::subject("maya".to_owned())));
    let ken = root.clone().with_viewer(Viewer::of(User::subject("ken".to_owned())));
    let post = maya
        .create_post()
        .id(Uuid::now_v7().to_string())
        .title("Launch notes")
        .body("An early draft")
        .save()
        .await?;
    let post_key = post.id.clone();
    ensure!(!ken.post_permissions(&post_key).can(PostPermission::Viewer).await?);
    maya.post_permissions(&post_key)
        .grant_viewer(User::subject("ken".to_owned()))
        .save()
        .await?;
    ensure!(ken.post_permissions(&post_key).can(PostPermission::Viewer).await?);
    ensure!(!ken.post_permissions(&post_key).can(PostPermission::Editor).await?);
    maya.post_permissions(&post_key)
        .grant_editor(User::subject("ken".to_owned()))
        .save()
        .await?;
    let edited = ken
        .update_post()
        .one(post_key.clone())
        .title("Reviewed launch notes")
        .save_one()
        .await?;
    ensure!(edited.title == "Reviewed launch notes");
    let comment = ken
        .create_comment()
        .id(Uuid::now_v7().to_string())
        .post_id(&post.id)
        .body("Ready for review")
        .save()
        .await?;
    ensure!(comment.author_id == "ken");
    let rows = ken.post().id_eq(&post.id).with_permissions().all().await?;
    ensure!(rows.len() == 1);
    ensure!(rows.first().is_some_and(|row| row.permissions.has(PostPermission::Editor)));
    let people = maya
        .user()
        .who_can(PostPermission::Viewer, &post_key)
        .order_by(User::name)
        .all()
        .await?;
    ensure!(people.subjects.iter().any(|person| person.id == "ken"));
    let explanation = ken.post_permissions(&post_key).explain(PostPermission::Viewer).await?;
    ensure!(explanation.is_some_and(|value| value.allowed));
    maya.post_permissions(&post_key)
        .revoke_editor(User::subject("ken".to_owned()))
        .await?;
    ensure!(ken.post_permissions(&post_key).can(PostPermission::Viewer).await?);
    ensure!(!ken.post_permissions(&post_key).can(PostPermission::Editor).await?);
    maya.post_permissions(&post_key)
        .revoke_viewer(User::subject("ken".to_owned()))
        .await?;
    ensure!(!ken.post_permissions(&post_key).can(PostPermission::Viewer).await?);
    ensure!(ken.comment().id_eq(&comment.id).all().await?.is_empty());
    println!("Maya shared a post with Ken, then removed his access to the discussion.");
    Ok(())
}

#[tokio::main]
async fn main() -> Result<()> {
    example(&std::env::var("DATABASE_URL")?).await
}

#[cfg(test)]
mod test_support;
#[cfg(test)]
mod tests {
    #[tokio::test]
    async fn sharing_and_revocation() -> anyhow::Result<()> {
        let (_directory, url) = super::test_support::database()?;
        super::example(&url).await
    }
}
test_support.rs
use anyhow::{Context, Result, ensure};
use std::process::Command;

pub fn database() -> Result<(tempfile::TempDir, String)> {
    let directory = tempfile::tempdir()?;
    std::fs::write(directory.path().join("schema.rsl"), include_str!("schema.rsl"))?;
    std::fs::write(directory.path().join("rent.toml"), r#"schema = "schema.rsl""#)?;
    let url = format!("sqlite://{}?mode=rwc", directory.path().join("app.db").display());
    let executable = std::env::var("RENT_CLI").unwrap_or_else(|_| "rent".into());
    let output = Command::new(executable)
        .arg("migrate")
        .arg("dev")
        .arg("--root")
        .arg(directory.path())
        .arg("--to")
        .arg("schema.rsl")
        .arg("--database-url")
        .arg(&url)
        .output()
        .context("run the Rent CLI")?;
    ensure!(
        output.status.success(),
        "migration failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    Ok((directory, url))
}