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:
| Declaration | Question |
|---|---|
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) = publisherEvery 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 runRun 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))
}