Skip to content
Concepts

Root and scoped clients

A request from Maya should see Maya's data. A request from Ken should see Ken's. Your handlers shouldn't have to remember who they are acting for at every query.

Create a client for the signed-in person once, then pass it through the request:

let request = root.clone().with_viewer(verified_viewer);

let posts = request.post().limit(25).all().await?;

That request is a scoped client. Its queries carry the viewer and tenant you bound to it, and use your schema's permission rules. The root client is the application's starting point: it connects to the database and configures shared behavior.

Keep one root, bind each request

Set up the root when your application starts:

use generated::client::{Client, RootClient, ScopedClient};

let root: RootClient = Client::connect(database_url).await?;

After authentication, derive a scoped client from verified identity information:

let request: ScopedClient = root.clone().with_viewer(verified_viewer);

Authentication is your application's job. Rent uses the identity you provide to apply tenancy and permissions. This distinction also works for ordinary schemas without relationship permissions.

Give services the client they need

A post service can hold the request's scoped client and use ordinary generated queries:

struct PostService {
    database: ScopedClient,
}

impl PostService {
    async fn list(&self) -> Result<Vec<Post>, rent::runtime::EntityError> {
        self.database.post().limit(25).all().await
    }
}

There is no special query language inside the service. It can read and write as the bound viewer, but it cannot call system() or replace that viewer. Clones and transactions keep the same scope:

request.transaction(async |tx| {
    tx.create_post().title("Private draft").save().await
}).await?;

An organization administrator uses a scoped client too. Their additional access comes from your permission rules, just as it does for any other person.

Give trusted jobs a narrow doorway

Some work belongs to the application itself: provisioning an account or appending a private audit record, for example. Keep that authority in a small trusted service rather than handing a root client to every handler.

When an audit record must commit with a user's change, a trusted adapter can join that request's transaction:

let transaction = request.tx().await?;
let scoped = transaction.client();
let audit = root.system_in(&scoped).await?;

scoped.create_post().title("Draft").save().await?;
audit.create_audit_event().action("post.create").save().await?;
transaction.commit().await?;

Both writes commit or roll back together. The post still uses the request's permissions. The trusted adapter uses system access only for its own small operation. The enterprise tutorial puts this into a document service.

Where to configure shared behavior

Configure policies, hooks, codecs, and caches on the root before deriving request clients. Keep a clone of that same root for trusted adapters. Constructing a root alone does not bypass permissions. system() is an explicit privileged operation.

The type boundary helps application code use the right authority. Code holding database credentials or a pool is still trusted code and can open its own connection.

If you're upgrading an older application, regenerate the client and change handler and service parameters from Client to ScopedClient. Keep RootClient at startup and inside the trusted adapters that actually need it. See the client authority reference for advanced methods.