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 = falseShow 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 runRun 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))
}