Skip to content
Tutorials

Browse as a viewer in Studio

Maya sees a post, but Ken says it is missing. Instead of guessing which sharing rule is responsible, open Studio as Ken and look at the data he can read:

rent studio --as 'User:{"tenant_id":"acme","id":"ken"}' --no-open

The browser uses Ken's read permissions and tenant scope. Posts and comments he can't read don't appear. Studio labels this mode viewer: read only so you can distinguish it from ordinary database browsing.

Choose the person and tenant

Use every field in the person's primary key. When that key includes tenant_id, Studio can infer the tenant. Otherwise provide --tenant acme.

To inspect what an anonymous visitor can read:

rent studio --anonymous --tenant acme --no-open

These are trusted local debugging inputs, not a login flow. Supply only the claims you intend to inspect. Groups and delegation can also be specified. See rent permissions explain for the same claim options.

Follow the application's read rules

Studio shows modeled tables from the configured schema. Private system models and unrelated tables don't become a way around the viewer's access. Viewer browsing can't be combined with --allow-writes.

Tenancy and permissions remain independent opt-ins. A tenant-only application can use viewer mode to check its tenant boundary without adding relationship permissions. For policies supplied only by application Rust code, inspect through the application's configured client rather than assuming Studio can reconstruct them from RSL.

When you want to understand why a rule matches, continue with rent permissions explain.

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