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