Skip to content
Tutorials

Explain inherited permissions

Ken can read a child post even though nobody shared that post with him directly. Its parent was shared with him. An explanation can show that the parent's rule is the reason for his access.

Describe inheritance in RSL

For a Post with author and parent relationships:

permissions Post {
  viewer = author or parent.viewer
  read = viewer
  create, update, delete = nobody
}

This read-only example follows the parent until a relationship supplies access. The hierarchy tutorial shows the models and how to allow writes too.

Ask about the child

let explanation = ken.post_permissions(&post_id)
    .explain(PostPermission::Viewer)
    .await?;

The explanation reports which branch matched: author, parent.viewer, or both. It doesn't include the parent's private row contents. A missing or unreadable child returns None, just as it does in the basic explanation example.

Explain delegation independently

If an agent is acting for Ken, actor and represented contain separate explanation trees. This lets a support screen distinguish the agent's own access from Ken's access, rather than presenting them as one unexplained identity.

The configured delegation mode combines their decisions. With intersect, both need to satisfy the rule, even if they qualify through different relationships. See delegation.

Handle a hierarchy that is too deep

An explanation that can't finish within the configured limits returns an error, not a partial answer that looks like a completed denial. Tenant boundaries still apply throughout the hierarchy. The reference covers those limits when you need to configure deeper trees.

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