Skip to content
Tutorials

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 draftAccess through these rules
Maya herselfAllowed
Eligible Scout representing MayaAllowed
Scout aloneDenied
Scout representing KenDenied
Scout representing Maya after helper revocationDenied

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 run

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