Skip to content
Tutorials

Authorize individual update fields

A member should be able to leave an organization without gaining the ability to change their role. An administrator can manage ordinary members, but only an owner should be able to make someone else an owner.

These are permission decisions about particular fields. RSL lets you express them without giving every update the same level of authority.

Allow one self-service change

In this example, Membership has an active field and a principal relationship identifying the member. Its organization relationship provides the owner's and administrator's permissions.

permissions Membership {
  read = anyone
  create, delete = organization.owner

  // Owners can edit any membership; admins can edit non-owner memberships.
  update = organization.owner
    or organization.administrator when role: [ADMIN, MEMBER]

  // A member may also change this field on their own membership.
  update(active) = principal

  // For self-service, the allowed new value is false: leaving, not rejoining.
  update_to(active) = organization.owner or organization.administrator
    or principal when active: false

  // Only an owner may assign OWNER.
  update_to(role) = organization.owner
    or organization.administrator when role: [ADMIN, MEMBER]
}

active and role are model fields. principal, organization, owner, and administrator are application-defined relationships or rules. Rent doesn't supply a built-in organization hierarchy.

The two declarations answer different questions:

DeclarationQuestion
update(active)Who may change this field?
update_to(active)Given that authority, is this resulting change allowed?

A member can set their own active to false, but can't use that narrow permission to set it back to true or change their role. An administrator who can edit a membership still can't promote it to OWNER unless they also satisfy the owner's rule.

Keep the ordinary update builder

Leaving the organization uses the same generated setter as any other update:

let left = member.update_membership()
    .one(membership_id.clone())
    .active(false)
    .save_one()
    .await?;

Trying to include a role change doesn't turn the narrow grant into a whole-row grant:

let promotion = member.update_membership()
    .one(membership_id)
    .active(false)
    .role(Role::Owner)
    .save_one()
    .await;

assert!(promotion.is_err());

The rejected operation leaves the membership unchanged. It doesn't apply just the allowed part of the request.

Grant a small bundle of fields

A writing application might give editors control of content while reserving publication settings for another role:

// Inside a Post permission block.
update(title, body) = editor
update(published) = publisher

Every field being assigned needs permission. Full update authority can provide it, or a field-specific grant can provide it for the named fields.

Use these rules for access decisions

Use field permissions for questions such as who may change a role, publish content, or deactivate a membership. Keep title length, form validation, and business rules such as retaining an active organization owner in application code.

Ordinary update rules describe access to the stored row. Inline when conditions in update_to describe the resulting values. Following a relationship doesn't silently switch that relationship to a new identity. See the field-permission reference when working with ownership transfers or more involved update rules.

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-40-field-permissions"
version = "0.1.0"
edition = "2024"

[workspace]

[[bin]]
name = "rent-lesson-40-field-permissions"
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]
}

enum Role {
  OWNER
  ADMIN
  MEMBER
}

model User {
  id   BigInt  @id
  name String
}

model Organization {
  id               BigInt  @id  @default(autoincrement())
  owner_id         BigInt
  administrator_id BigInt
  owner            User    @relation("OrganizationOwner", fields: [owner_id], references: [id])  @rent.creator
  administrator    User    @relation("OrganizationAdministrator", fields: [administrator_id], references: [id])
}

model Membership {
  id              BigInt        @id  @default(autoincrement())
  organization_id BigInt
  principal_id    BigInt
  role            Role          @default(MEMBER)
  active          Boolean       @default(true)
  organization    Organization  @relation(fields: [organization_id], references: [id])
  principal       User          @relation(fields: [principal_id], references: [id])
}

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

permissions Organization {
  read, create   = anyone
  update, delete = owner
}

permissions Membership {
  read              = anyone
  create, delete    = organization.owner
  update            = organization.owner or organization.administrator when role: [ADMIN, MEMBER]
  update(active)    = principal
  update_to(active) = organization.owner
    or organization.administrator
    or principal when active: false
  update_to(role)   = organization.owner or organization.administrator when role: [ADMIN, MEMBER]
}
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::{enums::Role, user::User};
async fn example(url: &str) -> Result<()> {
    let root = Client::connect(url).await?;
    for (id, name) in [(1, "Maya"), (2, "Eve"), (3, "Ken")] {
        root.system()
            .upsert_user(generated::user::unique::id)
            .id(id)
            .name(name)
            .save()
            .await?;
    }
    let owner = root.clone().with_viewer(Viewer::of(User::subject(1)));
    let member = root.clone().with_viewer(Viewer::of(User::subject(3)));
    let organization = owner.create_organization().administrator_id(2).save().await?;
    let membership = owner
        .create_membership()
        .organization_id(organization.id)
        .principal_id(3)
        .save()
        .await?;
    let left = member.update_membership().one(membership.id).active(false).save_one().await?;
    ensure!(!left.active);
    ensure!(
        member
            .update_membership()
            .one(membership.id)
            .active(true)
            .save_one()
            .await
            .is_err()
    );
    ensure!(
        member
            .update_membership()
            .one(membership.id)
            .role(Role::Owner)
            .save_one()
            .await
            .is_err()
    );
    let unchanged = owner.membership().id_eq(membership.id).only().await?;
    ensure!(unchanged.role == Role::Member && !unchanged.active);
    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))
}