Recursive permission reads
A handbook has a main post and several pages underneath it. If Maya shares the handbook with Ken, he should be able to read those pages without Maya sharing each one separately.
Describe that inheritance by following a parent relationship. The same idea works
for folders, project trees, and nested collections.
Read the schema
Add a parent and children to your Post model:
model Post {
id BigInt @id
title String
author_id BigInt
author User @relation(fields: [author_id], references: [id]) @rent.creator
parent_id BigInt?
parent Post? @relation("PostTree", fields: [parent_id], references: [id])
children Post[] @relation("PostTree")
}This extends the User/Post models from the permissions concept.
The optional parent makes a top-level post possible. PostTree connects the two
ends of this self-relationship. parent and children are names we chose.
Now let readers and editors inherit from the parent:
permissions Post {
editor = author or [User] or parent.editor
viewer = editor or [User] or parent.viewer
read = viewer
create = anyone
update = editor
delete = editor
manage_grants = author
}parent.viewer means “use my parent's viewer rule.” That parent can follow its own
parent, so one share can reach several levels down the tree. A post without a parent
can still grant access through its author or direct assignments.
In this policy an inherited editor can also delete a child. Keep delete = author
instead if only the child's author should be able to delete it.
Query with inherited access
Ken uses an ordinary query to list the posts he can read:
let posts = ken.post()
.order_by(Post::id)
.all()
.await?;
let handbook = ken.post()
.id_eq(handbook_id)
.with_children(|children| children.order_by(Post::id))
.only()
.await?;The result includes accessible descendants, and the related children are filtered by their own read rules too. You don't write a separate “check all the ancestors” loop.
A direct assignment can provide another route to access. Removing a parent share removes that inherited route, but doesn't erase a person's independent share on a child.
Delete with inherited permission
With delete = editor, an inherited editor uses the normal delete builder:
ken.delete_post()
.id_eq(page_id)
.exec()
.await?;The operation checks the current permission. Reading the page earlier doesn't reserve the right to delete it later. A bulk delete follows the same rule for each selected row. Use an application transaction when deletion is part of a larger command.
Create through an existing parent
Sometimes being able to edit a collection should also let you add entries to it.
Give Entry a relationship named parent to Post, then follow the parent's rule:
permissions Entry {
read = parent.viewer
create = parent.editor
update = parent.editor
delete = parent.editor
}An editor of the handbook can now add an entry using the ordinary builder:
let entry = ken.create_entry()
.id(10)
.parent_id(handbook_id)
.body("How to get started")
.save()
.await?;The parent must already exist and provide the authority. Creating a child doesn't give someone new rights over its parent. Top-level provisioning needs its own application policy because there is no parent to authorize it.
Move a record to another parent
Changing an entry's parent changes where it belongs. Rent checks both permission to update the current entry and the create rule for its new parent.
ken.update_entry()
.id_eq(entry.id)
.parent_id(other_handbook_id)
.save_one()
.await?;Ken needs the destination's editor permission too. A rejected move leaves the entry where it was. See mutation rules for how create rules apply when relationship keys change.
Create several children through existing parents
let entries = ken.create_entry_many([
ken.create_entry().id(11).parent_id(handbook_id).body("Install"),
ken.create_entry().id(12).parent_id(handbook_id).body("Configure"),
])
.save()
.await?;The batch succeeds together or rolls back together. Each entry needs an authorized existing parent, including when a batch contains entries for different handbooks.
Let the database generate child IDs
The same pattern works with an auto-incrementing identity:
model Entry {
id BigInt @id @default(autoincrement())
body String
parent_id BigInt
parent Post @relation(fields: [parent_id], references: [id])
}Omit .id(...) when creating an entry. Its permission comes from the supplied parent,
so you don't need to choose an ID in application code.
Manage grants through inherited authority
Sharing doesn't have to follow editing. Our Post policy keeps manage_grants = author.
If your product lets parent editors manage child sharing, choose that explicitly:
// Replace manage_grants in Post's permission block.
manage_grants = author or parent.editorThen use the same generated grant_viewer and revoke_viewer methods as in
direct sharing.
Keep the hierarchy inside its tenant
For a multi-tenant application, include the tenant in the parent/child keys so a local ID in another tenant cannot become a parent. The tenant-safe schema shows those compound keys.
Inherited permission rules have configured traversal limits. If Rent can't finish a required decision within those limits, it returns an error rather than partial access. A cycle alone never creates permission. See the reference for depth settings.
Next, explain inherited access or show who inherits access in your application.
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-25-recursive-permissions"
version = "0.1.0"
edition = "2024"
[workspace]
[[bin]]
name = "rent-lesson-25-recursive-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[]
parent_id String?
parent Post? @relation("PostTree", fields: [parent_id], references: [id])
children Post[] @relation("PostTree")
entries Entry[]
}
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] or parent.editor
viewer = editor or [User] or parent.viewer
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
}
model Entry {
id String @id
title String
parent_id String
parent Post @relation(fields: [parent_id], references: [id])
}
permissions Entry {
read = parent.viewer
create, update, delete = parent.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_editor(User::subject("ken".to_owned()))
.save()
.await?;
let child = maya
.create_post()
.id(Uuid::now_v7().to_string())
.title("Handbook page")
.body("Inherited access")
.parent_id(&post.id)
.save()
.await?;
let child_key = child.id.clone();
ensure!(ken.post_permissions(&child_key).can(PostPermission::Viewer).await?);
let inherited = ken.post_permissions(&child_key).explain(PostPermission::Viewer).await?;
ensure!(inherited.is_some_and(|explanation| explanation.allowed));
let audience = maya.user().who_can(PostPermission::Viewer, &child_key).all().await?;
ensure!(audience.subjects.iter().any(|person| person.id == "ken"));
let entry = ken
.create_entry()
.id(Uuid::now_v7().to_string())
.title("Review checklist")
.parent_id(&post.id)
.save()
.await?;
ensure!(entry.parent_id == post.id);
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.comment().id_eq(&comment.id).all().await?.is_empty());
ensure!(!ken.post_permissions(&child_key).can(PostPermission::Viewer).await?);
let audience = maya.user().who_can(PostPermission::Viewer, &child_key).all().await?;
ensure!(audience.subjects.iter().all(|person| person.id != "ken"));
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))
}