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