Skip to content
Tutorials

Show people with access

Maya opens a post's sharing dialog. She wants to see that Ken can read it, not just whether Maya herself has access.

Query User with who_can, giving it the post and the permission you want to list:

let viewers = maya.user()
    .who_can(PostPermission::Viewer, &post_key)
    .order_by(User::name)
    .all()
    .await?;

for person in &viewers.subjects {
    println!("{}", person.name);
}

This uses the User/Post/Comment policy, where viewer = editor or [User]. Someone can appear because they authored the post, received a direct share, or belongs to a team granted access by your policy.

Tell the UI what the list means

The result includes more than the people:

FieldMeaning
subjectsReadable stored users who match the rule as themselves
completeWhether the result represents the complete audience
unresolvedCategories of access the stored-user list can't fully enumerate

For example, a rule allowing anonymous visitors doesn't give you a database row for each possible visitor. A trusted external group claim may also represent people outside the stored membership data. A query window can leave more matching people outside the current result.

When complete is false, label the UI accordingly: “People with known access” can be more accurate than “Everyone with access.” Don't treat an omitted person as proof that they can never gain access with additional trusted claims.

Respect the person opening the dialog

Maya must be able to read the post and the users shown in its sharing list. The query doesn't become an administrator directory lookup, and it doesn't expose another tenant's identities. The returned people are checked as themselves, without borrowing Maya's administrator or group claims.

The dialog describes access. It doesn't grant authority to share. Changing an assignment still requires the post's manage_grants permission. See granting and revoking access.

For parent-based access, continue with sharing inherited access. For why one person matches a rule, use permission 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-27-tenant-safe-sharing"
version = "0.1.0"
edition = "2024"

[workspace]

[[bin]]
name = "rent-lesson-27-tenant-safe-sharing"
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(Uuid::now_v7().to_string())
        .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));
    maya.post_permissions(&post_key)
        .revoke_editor(User::subject(UserId {
            tenant_id: "acme".into(),
            id: "ken".into(),
        }))
        .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))
}