Skip to content
Tutorials

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.editor

Then 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 run

Run 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))
}