Skip to content
Tutorials

Share inherited access

A sharing dialog should explain the audience of a child post even when nobody shared that child directly. Maya may be able to read it because she authored its parent.

Use the same who_can query as for direct access. The rule describes the inheritance:

permissions Post {
  viewer = author or parent.viewer
  read = viewer
  create, update, delete = nobody
}

Here author and parent are ordinary Post relationships. Start with the hierarchy example for the models.

Find people who inherit access

let people = client.user()
    .who_can(PostPermission::Viewer, &post_id)
    .order_by(User::name)
    .all()
    .await?;

If Maya authored the parent, she appears in people.subjects. The child's own author can appear too. Each person qualifies through their own relationships. The person opening the dialog doesn't lend their access to everyone in the result.

Both the post and the returned users must be readable to the person opening the dialog. In a tenant-scoped application, the query stays inside that tenant.

Be clear about the audience you can list

Check complete and unresolved before calling the result “everyone with access.” External claims or broad anonymous access may represent an audience that stored User rows can't enumerate. See sharing-dialog results.

To authorize an action, use the actual person's scoped client. A directory result isn't a substitute for their current trusted identity and claims.

Page the matching subjects

let page = client.user()
    .who_can(PostPermission::Viewer, &post_id)
    .order_by(User::id)
    .limit(25)
    .offset(25)
    .all()
    .await?;

The window applies to people who match the rule. A page isn't the whole audience, so the result includes QueryWindow in unresolved. An unfinished permission traversal returns an error instead of a partial list presented as complete.

Use permission explanations to show why a particular person inherits access.

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-25-recursive-permissions"
version = "0.1.0"
edition = "2024"

[workspace]

[[bin]]
name = "rent-lesson-25-recursive-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[]
  parent_id String?
  parent    Post?      @relation("PostTree", fields: [parent_id], references: [id])
  children  Post[]     @relation("PostTree")
  entries   Entry[]
}

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] or parent.editor
  viewer                = editor or [User] or parent.viewer
  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
}

model Entry {
  id        String  @id
  title     String
  parent_id String
  parent    Post    @relation(fields: [parent_id], references: [id])
}

permissions Entry {
  read                   = parent.viewer
  create, update, delete = parent.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_editor(User::subject("ken".to_owned()))
        .save()
        .await?;
    let child = maya
        .create_post()
        .id(Uuid::now_v7().to_string())
        .title("Handbook page")
        .body("Inherited access")
        .parent_id(&post.id)
        .save()
        .await?;
    let child_key = child.id.clone();
    ensure!(ken.post_permissions(&child_key).can(PostPermission::Viewer).await?);
    let inherited = ken.post_permissions(&child_key).explain(PostPermission::Viewer).await?;
    ensure!(inherited.is_some_and(|explanation| explanation.allowed));
    let audience = maya.user().who_can(PostPermission::Viewer, &child_key).all().await?;
    ensure!(audience.subjects.iter().any(|person| person.id == "ken"));
    let entry = ken
        .create_entry()
        .id(Uuid::now_v7().to_string())
        .title("Review checklist")
        .parent_id(&post.id)
        .save()
        .await?;
    ensure!(entry.parent_id == post.id);
    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.comment().id_eq(&comment.id).all().await?.is_empty());
    ensure!(!ken.post_permissions(&child_key).can(PostPermission::Viewer).await?);
    let audience = maya.user().who_can(PostPermission::Viewer, &child_key).all().await?;
    ensure!(audience.subjects.iter().all(|person| person.id != "ken"));
    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))
}