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.
| Option | Default | What you choose |
|---|---|---|
check_enabled | true | Reuse completed explicit decisions |
relationship_enabled | true | Reuse relationship evidence between checks |
ttl | 10 seconds | Completed-decision lifetime |
relationship_ttl | 10 seconds | Relationship-evidence lifetime |
max_entries | 10,000 | Combined entry limit |
max_weight_bytes | 4 MiB | Combined capacity budget |
max_relationships_per_entry | 10,000 | Maximum relationships in one reusable entry |
jitter_percent | 0 | Extra 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 runRun 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))
}