Skip to content
Tutorials

Explain a permission decision

Ken can read Maya's post, but he isn't its author. Is it because she shared it with him directly, or because he can edit it?

An explanation follows the rule names you wrote in RSL, so you can connect the answer to your application's policy.

Start with the sharing rule

In the post-sharing example, editors can also view:

permissions Post {
  editor = author or [User]
  viewer = editor or [User]

  read          = viewer
  create        = anyone
  update        = editor
  delete        = author
  manage_grants = author
}

Maya assigns Ken as an editor. He has no separate viewer assignment, but he can read because viewer includes editor.

Ask as Ken

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

if let Some(explanation) = explanation {
    println!("Can view: {}", explanation.allowed);
    for branch in &explanation.actor.children {
        println!("{}: {}", branch.expression, branch.matched);
    }
}

An explanation of this policy has a shape like:

viewer: allowed
  editor: allowed
    author: no
    [User]: yes
  [User]: no

Read it from the outside in: Ken is a viewer because he is an editor, and he is an editor because of a direct User assignment. The tree uses your schema expressions, so it also helps when a named rule is reused by several actions.

Keep private resources private

A readable resource can have an explanation whose allowed value is false—for example, Ken's delete permission. An unreadable or missing resource returns None instead of revealing its policy details.

Relationship branches report whether they matched. They don't reveal the related row's private contents. An explanation uses the client's current viewer. If your support tooling lets someone inspect as another person, authorize that operation in your application before constructing that viewer.

Follow inherited and delegated access

For a child that follows parent.viewer, use Explain inherited access. A delegated request has separate actor and represented trees, letting you see each party's part in the decision.

Explanations describe access at inspection time. They don't authorize a later write. They also differ from a query's .explain(), which shows a database query plan. See the reference for result fields and evaluation errors.

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