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"}' \
--jsonThe 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 launchThe 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 runRun 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))
}