Skip to content
Tutorials

Relationship permissions

Maya is writing a post. She wants Ken to read an early draft, and later help edit it. She still wants to control who else can see it and whether it gets deleted.

This tutorial turns that ordinary sharing flow into a few RSL rules and generated Rust calls. It uses a single-key User/Post/Comment schema, included with the complete program at the end. You do not need tenancy to learn this sharing model.

Describe the sharing policy

permissions Post {
  editor = author or [User]
  viewer = editor or [User]

  read          = viewer
  create        = anyone
  update        = editor
  delete        = author
  manage_grants = author
}

author is Post's relationship to User. editor and viewer are names we chose for the two access rules. [User] lets Maya assign individual people to either rule on a particular post. It doesn't give every user access.

A reader can read. An editor can read and edit. Only the author can delete or change sharing. These choices are your policy, so you can name and combine rules differently.

Generate the client from your schema and apply the database changes before using it:

rent generate
rent migrate dev --name add_post_permissions

Start with the generated client

After your application verifies a login, bind that person's complete identity:

use generated::{
    client::Client,
    post::{Post, PostPermission},
    user::User,
};
use rent::policy::viewer::Viewer;

let root = Client::connect(&database_url).await?;
let ken_id = "ken".to_owned();

let maya = root.clone().with_viewer(
    Viewer::of(User::subject("maya".to_owned())),
);
let ken = root.clone()
    .with_viewer(Viewer::of(User::subject(ken_id.clone())));

The user accounts already exist. The application supplies these identities from trusted authentication, not from an editable request body. See scoped clients for the handler/service pattern.

Create Maya's private draft:

let draft = maya.create_post()
    .id("launch")
    .title("Launch notes")
    .body("An early draft")
    .save()
    .await?;

The schema's @rent.creator fills in Maya as the author. The draft won't appear in Ken's post list until Maya shares it.

Assign a user or a team's members

Use the post's complete key to give Ken read access:

let post_id = draft.id.clone();

maya.post_permissions(&post_id)
    .grant_viewer(User::subject(ken_id.clone()))
    .save()
    .await?;

let posts = ken.post().order_by(Post::id).all().await?;

The post now appears in Ken's list. Other private posts remain hidden. There is no schema change or code-generation step when you grant or remove access.

When Ken is ready to help edit, grant the editor rule:

maya.post_permissions(&post_id)
    .grant_editor(User::subject(ken_id.clone()))
    .save()
    .await?;

ken.update_post()
    .one(post_id.clone())
    .title("Launch notes, reviewed")
    .save_one()
    .await?;

The team example extends this flow to share with a team's members. Start with individual sharing here. The same ideas carry forward when you add teams.

Remove just the access you assigned

maya.post_permissions(&post_id)
    .revoke_editor(User::subject(ken_id.clone()))
    .await?;

Ken can no longer edit, but he can still read because his separate viewer assignment remains. Revoke that assignment too when he should lose both routes:

maya.post_permissions(&post_id)
    .revoke_viewer(User::subject(ken_id))
    .await?;

For a temporary invitation, add .expires_at(deadline) before saving a grant.

Check one resource

A UI can ask whether to offer an Edit action:

let can_edit = ken.post_permissions(&post_id)
    .can(PostPermission::Editor)
    .await?;

This describes access now. An eventual update checks permission again, so a button shown earlier doesn't let Ken keep editing after access is revoked. For a list of posts, load permission flags with the rows.

Create several authorized rows together

Use the ordinary bulk builder when creating several drafts:

let drafts = maya.create_post_many([
    maya.create_post().id("first").title("First").body("Draft one"),
    maya.create_post().id("second").title("Second").body("Draft two"),
])
.save()
.await?;

Each draft uses the create rule and the same trusted creator context. If any row cannot be created, the whole batch rolls back.

Continue the discussion

Comments can follow a post's readers while keeping their own authorship rules. Follow tenant-safe sharing for the complete schema, or inherited permissions when posts belong to a hierarchy.

For upserts, relationship changes, computed values and detailed write behavior, see the permissions reference.

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

[workspace]

[[bin]]
name = "rent-lesson-24-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[]
}

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]
  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::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_viewer(User::subject("ken".to_owned()))
        .save()
        .await?;
    ensure!(ken.post_permissions(&post_key).can(PostPermission::Viewer).await?);
    ensure!(!ken.post_permissions(&post_key).can(PostPermission::Editor).await?);
    maya.post_permissions(&post_key)
        .grant_editor(User::subject("ken".to_owned()))
        .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("ken".to_owned()))
        .await?;
    ensure!(ken.post_permissions(&post_key).can(PostPermission::Viewer).await?);
    ensure!(!ken.post_permissions(&post_key).can(PostPermission::Editor).await?);
    maya.post_permissions(&post_key)
        .revoke_viewer(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());
    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))
}