Skip to content
Tutorials

Inspect permissions from the terminal

Use rent permissions explain to inspect access as a specific person. In this example, Ken can view a shared post but cannot delete it. An unreadable resource stays hidden, and keys must include every identity component, including the tenant.

Explain a readable resource

For an application whose User and Post keys contain tenant_id and id:

rent permissions explain Post.viewer \
  --as 'User:{"tenant_id":"acme","id":"ken"}' \
  --id '{"tenant_id":"acme","id":"notes"}' \
  --json

The command loads the configured RSL schema and database URL, then uses Rent's ordinary permission runtime. Each compound key names every primary-key field. Its order in the JSON object is unimportant. Rent restores declaration order and preserves each declared type. Numeric keys are JSON numbers, not quoted strings. Text keys can contain colons.

For a scalar text key, the same syntax is shorter:

rent permissions explain Canvas.editor --as Principal:maya --id launch

The actor's tenant is inferred when its complete key includes the tenant field. Use --tenant acme when it does not, or when evaluating an anonymous viewer. A resource key never supplies the viewer's tenant scope.

Supply the claims you intend to inspect

--group Model:key adds a trusted actor group. --on-behalf-of Model:key adds a represented subject. Its own groups use --represented-group. --admin and --represented-admin set separate administrator claims. The schema's delegation mode determines how the two whole-rule decisions combine.

These are local inspection inputs supplied by the developer. Rent does not authenticate someone merely because their identifier appears in a command. Application request handlers must continue to construct claims from their trusted authentication system.

The explanation contains schema expressions, their documentation and their boolean results. It does not return intermediate relationship rows. A missing resource and an unreadable resource produce the same error. See permission explanations for the generated-client API and the command reference for all options.

The CLI evaluates the schema’s permissions and tenancy. Application-installed Rust privacy policies require inspection through the configured generated client. The CLI rejects schemas that declare such policy factories because it cannot instantiate them. Recursive explanations retain the same depth/work errors as ordinary permission checks. See inherited access explanations.

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-31-permissions-cli"
version = "0.1.0"
edition = "2024"

[workspace]

[[bin]]
name = "rent-lesson-31-permissions-cli"
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"
}

tenancy {
  scope = "required"
  field = "tenant_id"
}

access {
  subjects = [User]
}

model User {
  tenant_id String
  id        String
  name      String
  posts     Post[]
  comments  Comment[]

  @@id([tenant_id, id])
}

model Post {
  tenant_id String
  id        String
  title     String
  body      String
  author_id String
  author    User       @relation(fields: [tenant_id, author_id], references: [tenant_id, id])  @rent.creator
  comments  Comment[]

  @@id([tenant_id, id])
}

model Comment {
  tenant_id String
  id        String
  body      String
  post_id   String
  author_id String
  post      Post    @relation(fields: [tenant_id, post_id], references: [tenant_id, id], onDelete: Cascade)
  author    User    @relation(fields: [tenant_id, author_id], references: [tenant_id, id])  @rent.creator

  @@id([tenant_id, id])
}

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::{PostId, PostPermission},
    user::{User, UserId},
};

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::tenant_id_id)
        .tenant_id("acme")
        .id("maya")
        .name("Maya")
        .save()
        .await?;
    system
        .upsert_user(generated::user::unique::tenant_id_id)
        .tenant_id("acme")
        .id("ken")
        .name("Ken")
        .save()
        .await?;
    let maya = root.clone().with_viewer(
        Viewer::of(User::subject(UserId {
            tenant_id: "acme".into(),
            id: "maya".into(),
        }))
        .tenant("acme"),
    );
    let ken = root.clone().with_viewer(
        Viewer::of(User::subject(UserId {
            tenant_id: "acme".into(),
            id: "ken".into(),
        }))
        .tenant("acme"),
    );
    let post = maya
        .create_post()
        .id("notes")
        .title("Launch notes")
        .body("An early draft")
        .save()
        .await?;
    let post_key = PostId {
        tenant_id: "acme".into(),
        id: post.id.clone(),
    };
    ensure!(!ken.post_permissions(&post_key).can(PostPermission::Viewer).await?);
    maya.post_permissions(&post_key)
        .grant_editor(User::subject(UserId {
            tenant_id: "acme".into(),
            id: "ken".into(),
        }))
        .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));
    println!("Inspect Post.viewer for {{\"tenant_id\":\"acme\",\"id\":\"notes\"}}");
    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))
}