Skip to content
Tutorials

Policies, interceptors, and hooks

Keep the create and query calls from earlier examples. This time, give the client shared behavior: hide a particular directory entry and expand Maya's display name before saving it.

Restrict directory reads with a policy

This small policy leaves other operations alone and adds a condition to User queries. The caller's own filters still apply:

use rent::policy::Decision;
use rent::runtime::EntityPolicy;
use rent::sql::ast::{Comparison, field_compare};

let policy = EntityPolicy::deny_by_default().rule(|_context, operation| {
    if operation.entity == "User" && operation.query.is_some() {
        operation.predicate = Some(field_compare(
            "name",
            Comparison::Ne,
            "Hidden by policy",
        ));
    }
    Ok(Decision::Allow)
});

This is a deliberately small, application-specific directory rule. Use tenancy for customer isolation and RSL permissions for ownership and sharing. This policy adds a restriction. It does not replace either boundary.

A policy can also reject a mutation by returning Decision::Deny. For example, inspect operation.mutation, read its name field, and deny a reserved name before the write reaches the database.

Change a value with a hook

A before-write hook can adjust a submitted field. Here it changes only the name Maya on a User create:

use rent::policy::hook::{HookChain, HookStage, MutationValue, Operation};
use rent::runtime::{MutationContext, MutationOutcome};

let hooks = HookChain::<MutationOutcome, MutationContext>::default()
    .append(|event| {
        let is_maya = matches!(
            event.mutation.field("name"),
            Some(MutationValue::String(name)) if name == "Maya"
        );
        if event.stage == HookStage::Before
            && event.context.entity == "User"
            && event.mutation.operation == Operation::Create
            && is_maya
        {
            event.mutation.set("name", "Maya Chen");
        }
        Ok(())
    });

Hooks receive before, after, and error stages. An after-hook error rolls back the write. Use the transactions guide when handling a recoverable failure inside larger work.

Adjust or observe a query with an interceptor

Interceptors surround query execution. This one adds a second directory filter before the query runs. It can also inspect the outcome afterward:

use rent::policy::intercept::{InterceptStage, InterceptorChain};
use rent::runtime::QueryOutcome;

let interceptors = InterceptorChain::<QueryOutcome>::default()
    .append(|event| {
        if event.stage == InterceptStage::Before
            && event.query.entity == Some("User")
        {
            event.query.filters.push(field_compare(
                "name",
                Comparison::Ne,
                "Hidden by interceptor",
            ));
        }
        Ok(())
    });

Keep handlers ordinary

Attach the layers when constructing the client:

let client = Client::new(pool)
    .with_policy(policy)
    .with_interceptors(interceptors)
    .with_hooks(hooks);

let maya = client.create_user().id(1).name("Maya").save().await?;
let visible = client.user().all().await?;

Maya is saved as Maya Chen. Entries named Hidden by policy or Hidden by interceptor are omitted from visible. Application policies add restrictions. Interceptors customize query behavior. Hooks customize writes. The concept guide covers how these layers interact with relationships, transactions, permission assignments, and trusted raw SQL.

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.

rent generate
cargo run

The project includes its database setup. Each lesson uses a small, self-contained schema. You can run it without applying the earlier lessons.

Cargo.toml
[package]
name = "rent-lesson-10-middleware"
version = "0.1.0"
edition = "2024"

[workspace]

[[bin]]
name = "rent-lesson-10-middleware"
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"] }
rent.toml
schema = "schema.rsl"
generated = "generated"
migrations = "migrations"

[database]
dialect = "sqlite"
backends = ["sqlite"]
schema.rsl
datasource db {
  provider = "sqlite"
}

model User {
  id   BigInt  @id
  name String

  @@map("users")
}
main.rs
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};

use anyhow::{Result, ensure};
use rent::driver::DatabasePool;
use rent::policy::Decision;
use rent::policy::hook::{HookChain, HookStage, MutationValue, Operation};
use rent::policy::intercept::{InterceptStage, InterceptorChain};
use rent::runtime::{EntityPolicy, MutationContext, MutationOutcome, QueryOutcome};
use rent::sql::Dialect;
use rent::sql::ast::{Comparison, field_compare};

#[allow(dead_code, unused_imports)]
#[rustfmt::skip]
#[path = "generated/mod.rs"]
mod generated;

async fn exercise() -> Result<()> {
    let pool = DatabasePool::connect(Dialect::Sqlite, "sqlite::memory:").await?;
    pool.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)")
        .await?;
    let plain = generated::client::Client::new(pool.clone());
    plain.create_user().id(2).name("Hidden by policy").save().await?;
    plain.create_user().id(3).name("Hidden by interceptor").save().await?;

    let policy = EntityPolicy::deny_by_default().rule(|_context, operation| {
        if operation.entity == "User" {
            if matches!(
                operation.mutation.as_ref().and_then(|mutation| mutation.field("name")),
                Some(MutationValue::String(name)) if name == "Forbidden"
            ) {
                return Ok(Decision::Deny);
            }
            if operation.query.is_some() {
                operation.predicate = Some(field_compare("name", Comparison::Ne, "Hidden by policy"));
            }
        }
        Ok(Decision::Allow)
    });

    let query_events = Arc::new(AtomicUsize::new(0));
    let observed = Arc::clone(&query_events);
    let interceptors = InterceptorChain::<QueryOutcome>::default().append(move |event| {
        observed.fetch_add(1, Ordering::SeqCst);
        if event.stage == InterceptStage::Before && event.query.entity == Some("User") {
            event
                .query
                .filters
                .push(field_compare("name", Comparison::Ne, "Hidden by interceptor"));
        }
        Ok(())
    });

    let hooks = HookChain::<MutationOutcome, MutationContext>::default().append(|event| {
        if event.stage == HookStage::Before
            && event.mutation.operation == Operation::Create
            && matches!(event.mutation.field("name"), Some(MutationValue::String(name)) if name == "Maya")
        {
            event.mutation.set("name", "Maya Chen");
        }
        Ok(())
    });

    let client = generated::client::Client::new(pool)
        .with_policy(policy)
        .with_interceptors(interceptors)
        .with_hooks(hooks);
    let maya = client.create_user().id(1).name("Maya").save().await?;
    ensure!(maya.name == "Maya Chen");
    ensure!(client.user().all().await? == [maya]);
    ensure!(client.create_user().id(4).name("Forbidden").save().await.is_err());
    ensure!(query_events.load(Ordering::SeqCst) >= 2);
    Ok(())
}

#[tokio::main]
async fn main() -> Result<()> {
    exercise().await
}

#[cfg(test)]
mod tests {
    #[tokio::test]
    async fn example_runs() -> anyhow::Result<()> {
        super::exercise().await
    }
}