Skip to content
Tutorials

Cache explicit permission checks

A document screen may ask “can Maya edit this?” several times while she moves between panels. You can reuse that explicit permission decision instead of doing the same work again for every request.

Caching is optional. Start with fresh decisions, then enable reuse for checks where a short delay in noticing a changed grant is acceptable. Ordinary reads and writes still enforce their permissions. Attaching a cache doesn't turn authorization off.

Reuse an explicit check

Create one cache on the base client, then choose cached checks on a request client:

use rent::runtime::permission::{
    PermissionCacheOptions, PermissionConsistency, PermissionResultCache,
};

let cache = PermissionResultCache::new(PermissionCacheOptions::default())?;
let base = base.with_permission_result_cache(cache.clone());
let reader = base.clone()
    .with_viewer(verified_viewer)
    .with_permission_consistency(PermissionConsistency::Cached);

let allowed = reader.post_permissions(&post_id)
    .can(PostPermission::Viewer)
    .await?;

Keep the configured base and clone it for each request. Cache entries distinguish resources, people, tenants, and their trusted claims, so Ken's check doesn't become Maya's answer. Attaching a cache independently creates a separate client namespace.

Choose freshness deliberately

A cached decision can be an earlier allow or an earlier denial. When an operation needs a fresh decision, ask for it explicitly:

let allowed_now = reader.clone()
    .with_permission_consistency(PermissionConsistency::Fresh)
    .post_permissions(&post_id)
    .can(PostPermission::Viewer)
    .await?;

Fresh is the default. Reads, permission badges, explanations, and mutation authorization remain fresh, and checks inside a transaction use that transaction's snapshot. A cached Edit button never authorizes the later update by itself.

Pick a lifetime and capacity

For example, use a five-second result lifetime and a one-megabyte capacity budget:

use std::time::Duration;

let options = PermissionCacheOptions {
    ttl: Duration::from_secs(5),
    max_weight_bytes: 1024 * 1024,
    ..PermissionCacheOptions::default()
};

Rent can reuse both completed decisions and the relationship information used to answer them. The two lifetimes are separate, so a result lifetime alone isn't a promise of immediate revocation after an external change.

OptionDefaultWhat you choose
check_enabledtrueReuse completed explicit decisions
relationship_enabledtrueReuse relationship evidence between checks
ttl10 secondsCompleted-decision lifetime
relationship_ttl10 secondsRelationship-evidence lifetime
max_entries10,000Combined entry limit
max_weight_bytes4 MiBCombined capacity budget
max_relationships_per_entry10,000Maximum relationships in one reusable entry
jitter_percent0Extra randomized lifetime, as a percentage of the base TTL

Enabled lifetimes and limits must be positive. Lifetimes can be at most one year. A 10-second TTL with 10% jitter lasts between 10 and 11 seconds, and a hit doesn't restart its clock. Capacity is approximate cache accounting, not a process-memory limit.

Keep changes visible

Generated writes through clones of the configured client invalidate decisions when they can affect permissions. A normal content edit can preserve cached decisions when that content isn't used by a rule. A changed author, membership, or grant affects access and requires invalidation.

If another client or direct SQL changes the data, invalidate this process's cache after the change commits:

cache.invalidate_all();

A process-local cache can't automatically notice every other writer. Use fresh checks where immediate access changes matter, or configure the change controller for a multi-instance application.

Run multiple application instances

The optional controller lets application instances notice committed permission changes through their existing shared database. It runs inside each instance. You don't deploy a separate controller server or maintain a list of peers.

Install its change-log table once using a schema-privileged setup connection:

use rent::runtime::permission::{
    PermissionCacheController, PermissionCacheControllerOptions,
};

PermissionCacheController::install(
    &pool,
    "documents",
    PermissionCacheControllerOptions::default(),
).await?;

Then configure each base client, including writers, with the same store name:

let controller = PermissionCacheController::new(
    "documents",
    PermissionCacheControllerOptions::default(),
)?;
let cache = PermissionResultCache::new_with_controller(
    PermissionCacheOptions::default(),
    controller,
)?;
let base = base.with_permission_result_cache(cache);

Changes that affect permissions are recorded in the same transaction as the data. If recording fails, that write rolls back. Unrelated generated writes don't add a change record just because caching is enabled.

Instances check for changes in the background, by default at most once every ten seconds while cached requests are active. This is eventual consistency, so another instance can briefly use an earlier decision. Use Fresh when that delay isn't acceptable. Failed controller checks disable reuse until checking succeeds again.

Every writer must participate. For direct driver transactions, use controller.record_change(&mut transaction).await? before commit. Database-side or external changes absent from your schema need the same explicit notification. Keep the change log intact while application caches are live.

Check whether reuse helps

cache.stats() reports hits, misses, bypasses, invalidations, and approximate occupancy. Use it to see whether your application actually repeats these checks. Some checks deliberately bypass caching, including those with custom policies or application context. See the cache 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-36-permission-cache"
version = "0.1.0"
edition = "2024"

[workspace]

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

tenancy {
  scope = "required"
  field = "tenant_id"
}

access {
  subjects = [User]
}

model User {
  tenant_id String
  id        String
  name      String
  posts     Post[]
  comments  Comment[]

  @@id([tenant_id, id])
}

model Post {
  tenant_id String
  id        String
  title     String
  body      String
  author_id String
  author    User       @relation(fields: [tenant_id, author_id], references: [tenant_id, id])  @rent.creator
  comments  Comment[]

  @@id([tenant_id, id])
}

model Comment {
  tenant_id String
  id        String
  body      String
  post_id   String
  author_id String
  post      Post    @relation(fields: [tenant_id, post_id], references: [tenant_id, id], onDelete: Cascade)
  author    User    @relation(fields: [tenant_id, author_id], references: [tenant_id, id])  @rent.creator

  @@id([tenant_id, id])
}

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::{PostId, PostPermission},
    user::{User, UserId},
};

async fn example(database_url: &str) -> Result<()> {
    let cache = rent::runtime::permission::PermissionResultCache::new(Default::default())?;
    let root = Client::connect(database_url).await?.with_permission_result_cache(cache.clone());
    // Account provisioning is trusted application work, outside request handlers.
    let system = root.system();
    system
        .upsert_user(generated::user::unique::tenant_id_id)
        .tenant_id("acme")
        .id("maya")
        .name("Maya")
        .save()
        .await?;
    system
        .upsert_user(generated::user::unique::tenant_id_id)
        .tenant_id("acme")
        .id("ken")
        .name("Ken")
        .save()
        .await?;
    let maya = root.clone().with_viewer(
        Viewer::of(User::subject(UserId {
            tenant_id: "acme".into(),
            id: "maya".into(),
        }))
        .tenant("acme"),
    );
    let ken = root.clone().with_viewer(
        Viewer::of(User::subject(UserId {
            tenant_id: "acme".into(),
            id: "ken".into(),
        }))
        .tenant("acme"),
    );
    let post = maya
        .create_post()
        .id(Uuid::now_v7().to_string())
        .title("Launch notes")
        .body("An early draft")
        .save()
        .await?;
    let post_key = PostId {
        tenant_id: "acme".into(),
        id: post.id.clone(),
    };
    ensure!(!ken.post_permissions(&post_key).can(PostPermission::Viewer).await?);
    maya.post_permissions(&post_key)
        .grant_editor(User::subject(UserId {
            tenant_id: "acme".into(),
            id: "ken".into(),
        }))
        .save()
        .await?;
    let cached = ken
        .clone()
        .with_permission_consistency(rent::runtime::permission::PermissionConsistency::Cached);
    ensure!(cached.post_permissions(&post_key).can(PostPermission::Viewer).await?);
    ensure!(cached.post_permissions(&post_key).can(PostPermission::Viewer).await?);
    ensure!(cache.stats().hits > 0);
    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(UserId {
            tenant_id: "acme".into(),
            id: "ken".into(),
        }))
        .await?;
    ensure!(!ken.post_permissions(&post_key).can(PostPermission::Viewer).await?);
    ensure!(ken.comment().id_eq(&comment.id).all().await?.is_empty());
    ensure!(!cached.post_permissions(&post_key).can(PostPermission::Viewer).await?);
    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))
}