Skip to content
Tutorials

Reuse permission policies

Posts and notes often start with the same policy: the author owns them, they are private until shared, and a reader can't edit or share them onward.

You don't need to repeat that policy for every model. Give it a name and apply it with use.

Define the shared rules

permissions profile PrivateOwnedResource {
  owner = author
  reader = owner or [User]

  read = reader
  create, update, delete, manage_grants = owner
}

permissions Post {
  use PrivateOwnedResource
}

permissions Note {
  use PrivateOwnedResource
}

PrivateOwnedResource is a name we chose, as are owner and reader. permissions profile and use are RSL syntax. Both models must have the author relationship that the profile uses, and User must be a declared access subject.

Now a Post and a Note follow the same rules while keeping independent authors and sharing assignments. Sharing one Post doesn't share every Note by that author.

Put ownership on the models

The profile uses an existing relationship. It doesn't create ownership fields for you. Each model supplies those fields, directly or through a mixin:

mixin OwnedFields {
  id        BigInt @id
  author_id BigInt
  author    User @relation(fields: [author_id], references: [id]) @rent.creator
}

model Post {
  use OwnedFields
  title String
}

model Note {
  use OwnedFields
  body String
}

@rent.creator makes the creating person the author. Because owner = author, the new resource starts private to them. The permissions introduction shows the access opt-in and User model. Model mixins explains field reuse.

Share one resource

Maya can grant Ken the reader rule on one of her posts through the generated API:

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

Ken can now read that post. He gains neither editing nor sharing authority. Removing the assignment removes that route to access. It doesn't remove independent access provided by ownership or another relationship.

Assignments are ordinary database changes. The profile describes the policy, while the grant records who received it on each resource.

Keep special actions visible

A model can add a rule alongside its shared profile:

permissions Post {
  use PrivateOwnedResource
  publish = owner
}

A child can follow its parent's rules instead of needing a separate ownership model:

permissions Comment {
  read   = post.reader
  create = post.reader
  update = author and post.reader
  delete = post.owner or (author and post.reader)
}

Comments belong to their readable discussion, and a reader still can't edit someone else's comment. This extends a Comment model with post and author relationships.

Organize a growing schema

Keep profiles in any .rsl file under the configured schema directory. One profile can use another when their rule names don't conflict. Choose a different profile when a model needs different CRUD rules rather than silently overriding one rule.

After editing a profile, run rent generate. If you add a new assignable rule, review rent migrate diff for its grant storage too. You can inspect what a model uses with:

rent permissions show --model Post

Profiles remain optional, just like permissions. For a shared workspace policy, continue with enterprise permissions.

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-38-permission-profiles"
version = "0.1.0"
edition = "2024"

[workspace]

[[bin]]
name = "rent-lesson-38-permission-profiles"
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"] }
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    BigInt  @id
  name  String
  posts Post[]
  notes Note[]
}

mixin OwnedFields {
  id        BigInt  @id  @default(autoincrement())
  author_id BigInt
  author    User    @relation(fields: [author_id], references: [id])  @rent.creator
}

model Post {
  use OwnedFields
  title String
}

model Note {
  use OwnedFields
  body String
}

permissions User {
  read                   = anyone
  create, update, delete = nobody
}

permissions profile PrivateOwnedResource {
  owner                                 = author
  reader                                = owner or [User]
  read                                  = reader
  create, update, delete, manage_grants = owner
}

permissions Post {
  use PrivateOwnedResource
}

permissions Note {
  use PrivateOwnedResource
}
main.rs
use anyhow::{Result, ensure};
use rent::policy::viewer::Viewer;
#[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(url: &str) -> Result<()> {
    let root = Client::connect(url).await?;
    for (id, name) in [(1, "Maya"), (2, "Ken")] {
        root.system()
            .upsert_user(generated::user::unique::id)
            .id(id)
            .name(name)
            .save()
            .await?;
    }
    let maya = root.clone().with_viewer(Viewer::of(User::subject(1)));
    let ken = root.with_viewer(Viewer::of(User::subject(2)));
    let post = maya.create_post().title("Private draft").save().await?;
    let note = maya.create_note().body("Only Maya sees this").save().await?;
    ensure!(!ken.post_permissions(&post.id).can(PostPermission::Read).await?);
    maya.post_permissions(&post.id).grant_reader(User::subject(2)).save().await?;
    ensure!(ken.post_permissions(&post.id).can(PostPermission::Read).await?);
    ensure!(ken.note().id_eq(note.id).all().await?.is_empty());
    maya.post_permissions(&post.id).revoke_reader(User::subject(2)).await?;
    ensure!(!ken.post_permissions(&post.id).can(PostPermission::Read).await?);
    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 example_runs() -> 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))
}