Private data and delegated helpers
Maya asks Scout to review her private draft. Scout should see that draft while working for Maya. Scout working for Ken, or acting alone, should receive no private rows from Maya's draft.
Keep ownership and helper eligibility in the schema. Add a request condition to make the helper's access depend on who it represents.
Start with private ownership
The Post model has an author relationship to User. A PostHelper connects an
approved helper to that post through its user relationship:
permissions Post {
owner = author(enabled: true)
eligible_helper = helpers.user(service: true, enabled: true)
reader = owner or (eligible_helper and delegated_actor())
read, update = reader
create, delete = owner
}Maya qualifies through owner. Scout qualifies through the helper branch only
while it is the executing actor of a delegated request. Removing its helper row
or disabling its User removes that route to access.
delegated_actor() is a fact about the current request. It does not make an
arbitrary service eligible or establish that its credentials are valid.
Require the owner to qualify independently
Use intersect so both Scout and the person it represents must satisfy the rule:
access {
subjects = [User]
delegation = "intersect"
}When Rent evaluates Maya, delegated_actor() is false. She must pass through
ownership. If Scout represents Ken instead, Ken fails Maya's owner rule and the
request is denied. Rent rejects this condition in an inherit schema, where the
helper's decision could otherwise authorize access without the owner's decision.
The existing collaboration lesson also covers agents with independently assigned permissions and generic request intersections. Those intersections do not establish a represented owner and do not activate this condition.
Bind the verified pair
After authenticating Scout and validating its delegation from Maya, bind both identities through the ordinary viewer API:
let scout = Viewer::of(User::subject("scout".into()));
let delegated = root.clone().with_viewer(
scout.clone().on_behalf_of(SubjectClaims::of(User::subject("maya".into()))),
);
let draft = delegated.post().id_eq("draft").only_or_none().await?;The same helper acting alone receives no row:
let alone = root.clone().with_viewer(scout);
assert!(alone.post().id_eq("draft").only_or_none().await?.is_none());| Request for Maya's draft | Access through these rules |
|---|---|
| Maya herself | Allowed |
| Eligible Scout representing Maya | Allowed |
| Scout alone | Denied |
| Scout representing Ken | Denied |
| Scout representing Maya after helper revocation | Denied |
Let related work follow the same boundary
A comment can inherit the post's rule:
permissions Comment {
read, create, update, delete = post.reader
}Scout can add a review comment for Maya while delegated. The same standalone helper cannot read it through the generated Comment client. Creator stamping still identifies Scout as the actor who wrote the comment.
Use ordinary permission rules to decide which edits helpers can make. The Post rules above permit editing the draft while reserving post creation and deletion to its owner. Keep application validation for accepted input, credential expiry and permitted delegation. Rent enforces the resource rules on generated reads and writes, including lists, counts and relationship traversal.
For several organizations, combine this pattern with tenant boundaries. The permission reference defines the exact request, inspection and cache semantics.
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-44-private-delegation"
version = "0.1.0"
edition = "2024"
[workspace]
[[bin]]
name = "rent-lesson-44-private-delegation"
path = "main.rs"
[dependencies]
rent = { git = "RENT_REPOSITORY_URL", tag = "v0.8.6", default-features = false, features = ["sqlite", "migrate", "extensions"] }
anyhow = "1.0.104"
tokio = { version = "1.53.1", features = ["full"] }
tempfile = "3.27.0"rent.toml
schema = "schema.rsl"
generated = "generated"
migrations = "migrations"
[database]
dialect = "sqlite"
backends = ["sqlite"]schema.rsl
access {
subjects = [User]
delegation = "intersect"
}
model User {
id String @id
name String
service Boolean @default(false)
enabled Boolean @default(true)
}
model Post {
id String @id
title String
author_id String
author User @relation(fields: [author_id], references: [id]) @rent.creator
helpers PostHelper[]
comments Comment[]
}
model PostHelper {
post_id String
user_id String
post Post @relation(fields: [post_id], references: [id], onDelete: Cascade)
user User @relation(fields: [user_id], references: [id])
@@id([post_id, user_id])
}
model Comment {
id String @id
text String
post_id String
author_id String
post Post @relation(fields: [post_id], references: [id])
author User @relation(fields: [author_id], references: [id]) @rent.creator
}
permissions User {
read, create = anyone(enabled: true)
update, delete = nobody
}
permissions Post {
owner = author(enabled: true)
eligible_helper = helpers.user(service: true, enabled: true)
reader = owner or eligible_helper and delegated_actor()
read, update = reader
create, delete = owner
}
permissions PostHelper {
read, create, update, delete = post.owner
}
permissions Comment {
read, create, update, delete = post.reader
}main.rs
#![allow(dead_code, missing_docs)]
use anyhow::{Context as _, Result, ensure};
use rent::{
driver::DatabasePool,
policy::{identity::SubjectClaims, viewer::Viewer},
sql::Dialect,
};
#[allow(unused_imports)]
mod generated;
use generated::{client::Client, user::User};
async fn example(url: &str) -> Result<()> {
let root = Client::new(DatabasePool::connect(Dialect::Sqlite, url).await?)?;
let system = root.clone().system();
for (id, service) in [("maya", false), ("ken", false), ("scout", true)] {
system.create_user().id(id).name(id).service(service).save().await?;
}
let maya = root.clone().with_viewer(Viewer::of(User::subject("maya".into())));
let ken = root.clone().with_viewer(Viewer::of(User::subject("ken".into())));
maya.create_post().id("draft").title("Maya's draft").save().await?;
ken.create_post().id("kens-post").title("Ken's post").save().await?;
maya.create_post_helper().post_id("draft").user_id("scout").save().await?;
let scout = Viewer::of(User::subject("scout".into()));
let delegated = root
.clone()
.with_viewer(scout.clone().on_behalf_of(SubjectClaims::of(User::subject("maya".into()))));
let wrong_owner = root
.clone()
.with_viewer(scout.clone().on_behalf_of(SubjectClaims::of(User::subject("ken".into()))));
let alone = root.with_viewer(scout);
ensure!(delegated.post().id_eq("draft").exist().await?);
ensure!(!wrong_owner.post().id_eq("draft").exist().await?);
ensure!(!alone.post().id_eq("draft").exist().await?);
delegated
.update_post()
.id_eq("draft")
.title("Reviewed with Scout")
.save()
.await?;
let comment = delegated
.create_comment()
.id("feedback")
.post_id("draft")
.text("A clearer opening")
.save()
.await?;
ensure!(comment.author_id == "scout");
ensure!(delegated.post().id_eq("draft").query_comments().all().await?.len() == 1);
ensure!(alone.comment().all().await?.is_empty());
ensure!(wrong_owner.comment().all().await?.is_empty());
ensure!(alone.update_post().id_eq("draft").title("Denied").save().await? == 0);
ensure!(
alone
.create_comment()
.id("denied")
.post_id("draft")
.text("Denied")
.save()
.await
.is_err()
);
ensure!(maya.post().id_eq("draft").only_or_none().await?.context("private draft")?.title == "Reviewed with Scout");
maya.delete_post_helper().post_id_eq("draft").user_id_eq("scout").exec().await?;
ensure!(!delegated.post().id_eq("draft").exist().await?);
ensure!(maya.post().id_eq("draft").exist().await?);
println!(
"Scout can help Maya with her private draft only while representing her and remaining an eligible helper."
);
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 private_delegation_preserves_owner_and_helper_boundaries() -> 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))
}