Skip to content
Tutorials

Enterprise permissions and ordinary CRUD

Maya starts using the writing app on her own. Her posts and documents are private. Later, she creates Acme and invites Ken to help with Finance and Eve to work in Engineering. Ken should see the Finance budget, but joining the same company shouldn't reveal Engineering's documents.

You can model that with an organization, a few workspaces, and relationships between people and resources. This example is one policy you can build with Rent—not a set of enterprise objects every Rent application has to adopt.

Start with the places people work

Think of the organization as the tenant boundary, and a workspace as a place to share resources with a particular set of people:

Organization
├── Finance workspace
│   ├── Budget document
│   └── Forecast document
└── Engineering workspace
    └── Design document

A personal organization uses the same idea with one initial member. Your application can create its initial scope during onboarding so someone using the app alone doesn't have to configure an enterprise hierarchy first.

Joining the organization doesn't automatically grant every workspace's content. In this policy, even organization administration and document access are separate choices.

Let documents use their containing scope

A document's scope relationship says where its access comes from:

permissions Document {
  read   = scope.viewer
  create = scope.creator_permission
  update = scope.editor
  delete = scope.deleter
}

Read this as “people who can view this scope can read this document.” The other lines make the same connection for creating, editing, and deleting.

scope, viewer, creator_permission, editor, and deleter are definitions in this application. Here, a model named ResourceScope represents a workspace or a more specific sharing boundary. Documents and other resource types can point to it.

Share the Finance budget

Give Ken an Editor role on the Budget scope. In this example, a role assignment is an ordinary model connecting a principal, role, and scope:

request_client.create_member_binding()
    .scope_id("budget")
    .role_id("editor")
    .principal_id("ken")
    .save()
    .await?;

The sharing endpoint uses the current caller's scoped client. Its rules require sharing authority and prevent giving away capabilities the caller doesn't have.

Ken can edit Budget. That doesn't make him an editor of Forecast, or a member of Engineering, unless another assignment grants those routes. To remove this assignment:

request_client.delete_member_binding()
    .scope_id_eq("budget")
    .role_id_eq("editor")
    .principal_id_eq("ken")
    .exec()
    .await?;

No RSL edit or new JWT is needed. Roles and grants are application data. You can also share with a group and let group membership decide which people benefit.

Keep document handlers ordinary

After authenticating a request and checking the selected organization, bind its principal and tenant once:

let request_client = base.clone().with_viewer(
    Viewer::of(Principal::subject(PrincipalId {
        tenant_id: verified_tenant,
        id: verified_local_principal,
    }))
    .tenant(tenant_context),
);

A principal is the person or service account acting inside this tenant. Those values come from your trusted authentication and membership lookup.

The handler still uses ordinary generated queries and updates:

let documents = request_client.document()
    .order_by(Document::id)
    .limit(25)
    .all()
    .await?;

let changed = request_client.update_document()
    .one(document_id)
    .body(new_body)
    .save_one()
    .await?;

The list returns documents this person can read. Editing checks their update rule. The handler doesn't need to reconstruct the workspace's role hierarchy each time. See scoped clients for service boundaries.

Reuse the policy across resource types

A Guild, Document, and another application resource can stay distinct models while sharing the same common policy:

permissions profile ScopedResource {
  read   = scope.viewer
  create = scope.creator_permission
  update = scope.editor
  delete = scope.deleter
}

permissions Document {
  use ScopedResource
}

permissions Guild {
  use ScopedResource
}

Both models declare a scope relationship. A model mixin can share those fields too. A supporting record such as Comment can instead follow its parent Document's rule, so it needs no separate sharing screen.

Give a resource its own scope only when it needs independent sharing. Keeping several resources in one scope is useful when they should always follow the same audience.

Add a rule for a particular workflow

Suppose a document needs approval, but authors shouldn't approve their own work:

permissions Document {
  use ScopedResource

  approver = scope.approver but not creator
  approve  = approver
}

creator is the document's creator relationship. but not excludes that person from the scope's approvers. Other resource types keep their simpler shared policy.

Choose roles for the work people do

This example separates several responsibilities:

PersonResponsibility
Organization ownerManages organization ownership and membership
Security administratorManages groups, role definitions, and access administration
Workspace editorChanges resources in their assigned scope
Workspace readerReads resources in their assigned scope
GuestReceives a deliberately limited resource assignment
Service accountActs with its own assigned permissions

Roles can combine, but active organization membership is still required. Custom roles store the application's capability choices as data, so changing assignments doesn't require new code. Adding an entirely new capability does involve designing that capability in the schema and application.

A resource's creator and owner need not always be the same. Creating a document in a shared workspace can leave control with the workspace. When a product wants “the creator owns this new resource,” its create command explicitly establishes that ownership along with the resource.

Use identities in credentials, keep access in data

A JWT identifies the signed-in subject and session. The application verifies it. Membership, roles, and resource assignments live in the database. A service-account API key identifies that account and can place an additional ceiling on what the key may do. A read-only key must not gain write access simply because its account can edit.

Removing membership makes surviving grants ineffective in this policy. Rejoining with the same stable identity can make those grants apply again. Ordinary reads and writes use the current facts visible to their transaction.

Keep business commands together

Ownership transfer and membership removal often change several records. Keep those steps in an application transaction. For example, protecting the last active owner needs an application command that checks the invariant with appropriate locking. Field permissions can handle narrower self-service changes without turning permission rules into general business validation.

Similarly, a document, first comment, and audit event can commit together. Keep privileged audit writing in a narrow trusted adapter. Ordinary content handlers receive scoped clients. See trusted adapters.

For a smaller starting point, use private ownership and sharing. For efficient list pages and commands, continue with production access patterns.

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-37-enterprise-permissions"
version = "0.1.0"
edition = "2024"

[workspace]

[[bin]]
name = "rent-lesson-37-enterprise-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
// A reusable application permission model. All names except RSL keywords are ours.
datasource db {
  provider = "sqlite"
}

tenancy {
  scope = "required"
  field = "tenant_id"
}

access {
  subjects = [Principal]
}

enum PrincipalKind {
  HUMAN
  SERVICE
}

enum MemberRole {
  OWNER
  SECURITY_ADMIN
  BILLING_ADMIN
  MEMBER
  GUEST
  SERVICE
}

model Organization {
  tenant_id   String           @id  @rent.type(mysql: "varchar(64)")
  name        String
  memberships Membership[]
  principals  Principal[]
  groups      AccessGroup[]
  roles       Role[]
  scopes      ResourceScope[]
}

model Principal {
  tenant_id    String           @rent.type(mysql: "varchar(64)")
  id           String           @rent.type(mysql: "varchar(64)")
  name         String
  kind         PrincipalKind    @default(HUMAN)
  enabled      Boolean          @default(true)
  organization Organization     @relation(fields: [tenant_id], references: [tenant_id])
  memberships  Membership[]
  groups       GroupMember[]
  bindings     MemberBinding[]

  @@id([tenant_id, id])
}

model Membership {
  tenant_id    String        @rent.type(mysql: "varchar(64)")
  principal_id String        @rent.type(mysql: "varchar(64)")
  role         MemberRole    @default(MEMBER)
  active       Boolean       @default(true)
  organization Organization  @relation(fields: [tenant_id], references: [tenant_id])
  principal    Principal     @relation(fields: [tenant_id, principal_id], references: [tenant_id, id])

  @@id([tenant_id, principal_id])
}

model AccessGroup {
  tenant_id    String          @rent.type(mysql: "varchar(64)")
  id           String          @rent.type(mysql: "varchar(64)")
  name         String
  organization Organization    @relation(fields: [tenant_id], references: [tenant_id])
  members      GroupMember[]
  bindings     GroupBinding[]

  @@id([tenant_id, id])
}

model GroupMember {
  tenant_id    String       @rent.type(mysql: "varchar(64)")
  group_id     String       @rent.type(mysql: "varchar(64)")
  principal_id String       @rent.type(mysql: "varchar(64)")
  group        AccessGroup  @relation(fields: [tenant_id, group_id], references: [tenant_id, id])
  principal    Principal    @relation(fields: [tenant_id, principal_id], references: [tenant_id, id])

  @@id([tenant_id, group_id, principal_id])
}

// Custom roles are data: editing these flags requires no schema generation.
// The application owns the finite action catalog, not customer-supplied policy code.
model Role {
  tenant_id    String           @rent.type(mysql: "varchar(64)")
  id           String           @rent.type(mysql: "varchar(64)")
  name         String
  can_read     Boolean          @default(false)
  can_edit     Boolean          @default(false)
  can_comment  Boolean          @default(false)
  can_delete   Boolean          @default(false)
  can_share    Boolean          @default(false)
  can_create   Boolean          @default(false)
  can_approve  Boolean          @default(false)
  organization Organization     @relation(fields: [tenant_id], references: [tenant_id])
  people       MemberBinding[]
  groups       GroupBinding[]

  @@id([tenant_id, id])
}

// Security settings live here, away from fields an ordinary content editor can change.
model ResourceScope {
  tenant_id      String           @rent.type(mysql: "varchar(64)")
  id             String           @rent.type(mysql: "varchar(64)")
  name           String
  parent_id      String?          @rent.immutable  @rent.type(mysql: "varchar(64)")
  owner_id       String           @rent.type(mysql: "varchar(64)")
  created_by     String           @rent.type(mysql: "varchar(64)")
  inherit_access Boolean          @default(true)
  organization   Organization     @relation(fields: [tenant_id], references: [tenant_id])
  parent         ResourceScope?   @relation("ScopeTree", fields: [tenant_id, parent_id], references: [tenant_id, id])
  children       ResourceScope[]  @relation("ScopeTree")
  owner_subject  Principal        @relation("ScopeOwner", fields: [tenant_id, owner_id], references: [tenant_id, id])
  creator        Principal        @relation("ScopeCreator", fields: [tenant_id, created_by], references: [tenant_id, id])  @rent.creator
  people         MemberBinding[]
  groups         GroupBinding[]
  documents      Document[]
  guilds         Guild[]

  @@id([tenant_id, id])
}

model MemberBinding {
  tenant_id    String         @rent.type(mysql: "varchar(64)")
  scope_id     String         @rent.immutable  @rent.type(mysql: "varchar(64)")
  role_id      String         @rent.immutable  @rent.type(mysql: "varchar(64)")
  principal_id String         @rent.immutable  @rent.type(mysql: "varchar(64)")
  expires_at   DateTime?      @rent.expires
  scope        ResourceScope  @relation(fields: [tenant_id, scope_id], references: [tenant_id, id], onDelete: Cascade)
  role         Role           @relation(fields: [tenant_id, role_id], references: [tenant_id, id])
  principal    Principal      @relation(fields: [tenant_id, principal_id], references: [tenant_id, id])

  @@id([tenant_id, scope_id, role_id, principal_id])
}

model GroupBinding {
  tenant_id  String         @rent.type(mysql: "varchar(64)")
  scope_id   String         @rent.immutable  @rent.type(mysql: "varchar(64)")
  role_id    String         @rent.immutable  @rent.type(mysql: "varchar(64)")
  group_id   String         @rent.immutable  @rent.type(mysql: "varchar(64)")
  expires_at DateTime?      @rent.expires
  scope      ResourceScope  @relation(fields: [tenant_id, scope_id], references: [tenant_id, id], onDelete: Cascade)
  role       Role           @relation(fields: [tenant_id, role_id], references: [tenant_id, id])
  group      AccessGroup    @relation(fields: [tenant_id, group_id], references: [tenant_id, id])

  @@id([tenant_id, scope_id, role_id, group_id])
}

// Structured application data is independent of the permission rules.
type DocumentAppearance {
  theme        String
  show_outline Boolean
  page_width   Int
}

type DocumentNotifications {
  enabled  Boolean
  channels String[]
  digest   String
}

type DocumentRetention {
  archive_after_days Int
  keep_revisions     Boolean
  legal_hold         Boolean
}

type DocumentSettings {
  appearance    DocumentAppearance
  notifications DocumentNotifications
  retention     DocumentRetention
  locale        String
  custom        Json?
}

// A storage declaration shared by independently permissioned resource types.
// It creates no table; each receiving model gets ordinary fields and relations.
mixin TenantKey {
  tenant_id String  @rent.type(mysql: "varchar(64)")
}

mixin ScopedResourceFields {
  use TenantKey
  id         String         @rent.type(mysql: "varchar(64)")
  scope_id   String         @rent.immutable  @rent.type(mysql: "varchar(64)")
  created_by String         @rent.type(mysql: "varchar(64)")
  scope      ResourceScope  @relation(fields: [tenant_id, scope_id], references: [tenant_id, id])
  creator    Principal      @relation(fields: [tenant_id, created_by], references: [tenant_id, id])  @rent.creator

  @@id([tenant_id, id])
}

model Document {
  use ScopedResourceFields
  title        String
  body         String             @default("")  @rent.type(mysql: "text")
  summary      String?
  tags         String[]           @default([])
  metadata     Json               @default("{}")
  settings     DocumentSettings?
  published_at DateTime?
  archived     Boolean            @default(false)
  comments     Comment[]
  approvals    Approval[]
}

model Guild {
  use ScopedResourceFields
  name String
}

model Comment {
  tenant_id   String     @rent.type(mysql: "varchar(64)")
  id          String     @rent.type(mysql: "varchar(64)")
  document_id String     @rent.immutable  @rent.type(mysql: "varchar(64)")
  created_by  String     @rent.type(mysql: "varchar(64)")
  body        String
  document    Document   @relation(fields: [tenant_id, document_id], references: [tenant_id, id], onDelete: Cascade)
  creator     Principal  @relation(fields: [tenant_id, created_by], references: [tenant_id, id])  @rent.creator

  @@id([tenant_id, id])
}

model AuditEvent {
  tenant_id    String        @rent.type(mysql: "varchar(64)")
  id           String        @rent.type(mysql: "varchar(64)")
  actor_id     String
  action       String
  target_id    String
  organization Organization  @relation(fields: [tenant_id], references: [tenant_id])

  @@id([tenant_id, id])
}

permissions Organization {
  member         = memberships(active: true).principal(enabled: true)
  owner          = memberships(active: true, role: OWNER).principal(enabled: true)
  security_admin = owner or memberships(active: true, role: SECURITY_ADMIN).principal(enabled: true)
  billing_admin  = owner or memberships(active: true, role: BILLING_ADMIN).principal(enabled: true)
  contributor    = memberships(active: true, role: [OWNER, SECURITY_ADMIN, MEMBER]).principal(enabled: true, kind: HUMAN)
  read           = member
  create         = nobody
  update         = security_admin
  delete         = owner
}

permissions Principal {
  read                   = organization.member
  create, update, delete = nobody
}

permissions Membership {
  read                   = organization.security_admin or principal
  create, update, delete = nobody
  // Owners must use the lifecycle command, which protects the last active owner.
  update(active)         = principal(enabled: true)
    and organization.member when role: [SECURITY_ADMIN, BILLING_ADMIN, MEMBER, GUEST, SERVICE]
  update_to(active)      = principal(enabled: true) when active: false
}

permissions AccessGroup {
  member                 = members.principal(enabled: true) and organization.member
  read                   = organization.member
  create, update, delete = organization.security_admin
}

permissions GroupMember {
  read           = group.organization.member
  create, delete = group.organization.security_admin
  update         = nobody
}

permissions Role {
  allows_read            = public when can_read: true
  allows_edit            = public when can_edit: true
  allows_comment         = public when can_comment: true
  allows_delete          = public when can_delete: true
  allows_share           = public when can_share: true
  allows_create          = public when can_create: true
  allows_approve         = public when can_approve: true
  no_read                = public when can_read: false
  no_edit                = public when can_edit: false
  no_comment             = public when can_comment: false
  no_delete              = public when can_delete: false
  no_share               = public when can_share: false
  no_create              = public when can_create: false
  no_approve             = public when can_approve: false
  read                   = organization.member
  create, update, delete = organization.security_admin
}

permissions ResourceScope {
  owner               = owner_subject(enabled: true) and organization.member
  inherited_viewer    = parent.viewer when inherit_access: true
  inherited_editor    = parent.editor when inherit_access: true
  inherited_commenter = parent.commenter when inherit_access: true
  inherited_deleter   = parent.deleter when inherit_access: true
  inherited_sharer    = parent.sharer when inherit_access: true
  inherited_creator   = parent.creator_permission when inherit_access: true
  inherited_approver  = parent.approver when inherit_access: true
  editor              = (owner or people.editor or groups.editor or inherited_editor)
    and organization.member
  commenter           = (editor or people.commenter or groups.commenter or inherited_commenter)
    and organization.member
  viewer              = (commenter or people.viewer or groups.viewer or inherited_viewer)
    and organization.member
  deleter             = (owner or people.deleter or groups.deleter or inherited_deleter)
    and organization.member
  sharer              = (owner or people.sharer or groups.sharer or inherited_sharer)
    and organization.member
  creator_permission  = (owner or people.creator_permission or groups.creator_permission or inherited_creator)
    and organization.member
  approver            = (owner or people.approver or groups.approver or inherited_approver)
    and organization.member
  read                = viewer
  create              = owner_subject
    and organization.contributor
    and (parent.creator_permission or public when parent_id: null)
  update              = owner or organization.security_admin
  delete              = owner
}

permissions MemberBinding {
  viewer             = principal(enabled: true) and role.allows_read
  editor             = principal(enabled: true) and role.allows_edit
  commenter          = principal(enabled: true) and role.allows_comment
  deleter            = principal(enabled: true) and role.allows_delete
  sharer             = principal(enabled: true) and role.allows_share
  creator_permission = principal(enabled: true) and role.allows_create
  approver           = principal(enabled: true) and role.allows_approve
  grantable          = (scope.viewer or role.no_read)
    and (scope.editor or role.no_edit)
    and (scope.commenter or role.no_comment)
    and (scope.deleter or role.no_delete)
    and (scope.sharer or role.no_share)
    and (scope.creator_permission or role.no_create)
    and (scope.approver or role.no_approve)
  read               = scope.sharer or principal
  create             = scope.sharer and grantable
  update, delete     = scope.sharer
}

permissions GroupBinding {
  viewer             = group.member and role.allows_read
  editor             = group.member and role.allows_edit
  commenter          = group.member and role.allows_comment
  deleter            = group.member and role.allows_delete
  sharer             = group.member and role.allows_share
  creator_permission = group.member and role.allows_create
  approver           = group.member and role.allows_approve
  grantable          = (scope.viewer or role.no_read)
    and (scope.editor or role.no_edit)
    and (scope.commenter or role.no_comment)
    and (scope.deleter or role.no_delete)
    and (scope.sharer or role.no_share)
    and (scope.creator_permission or role.no_create)
    and (scope.approver or role.no_approve)
  read               = scope.sharer or group.member
  create             = scope.sharer and grantable
  update, delete     = scope.sharer
}

// Shared by every resource that follows its containing scope.
permissions profile ScopedResource {
  read   = scope.viewer
  create = scope.creator_permission
  update = scope.editor
  delete = scope.deleter
}

permissions Document {
  use ScopedResource
  approver = scope.approver but not creator
  approve  = approver
}

permissions Guild {
  use ScopedResource
}

permissions Comment {
  read   = document.scope.viewer
  create = document.scope.commenter
  update = creator and document.scope.commenter
  delete = creator and document.scope.commenter or document.scope.deleter
}

permissions AuditEvent {
  read                   = organization.security_admin
  create, update, delete = nobody
}

// A business action is an authorized, append-only fact, not a client-editable flag.
model Approval {
  tenant_id   String     @rent.type(mysql: "varchar(64)")
  document_id String     @rent.type(mysql: "varchar(64)")
  reviewer_id String     @rent.type(mysql: "varchar(64)")
  document    Document   @relation(fields: [tenant_id, document_id], references: [tenant_id, id])
  reviewer    Principal  @relation(fields: [tenant_id, reviewer_id], references: [tenant_id, id])  @rent.creator

  @@id([tenant_id, document_id, reviewer_id])
}

permissions Approval {
  read           = document.scope.viewer
  create         = document.approver
  update, delete = nobody
}
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::{
    client::ScopedClient,
    document::{Document, DocumentId, DocumentPermission},
    principal::{Principal, PrincipalId},
};
mod seed;
fn actor(
    root: &Client,
    tenant: &str,
    user: &str,
) -> ScopedClient {
    root.clone().with_viewer(
        Viewer::of(Principal::subject(PrincipalId {
            tenant_id: tenant.into(),
            id: user.into(),
        }))
        .tenant(tenant),
    )
}
async fn example(url: &str) -> Result<()> {
    let root = Client::connect(url).await?;
    seed::run(&root).await?;
    let maya = actor(&root, "acme", "maya");
    let ken = actor(&root, "acme", "ken");
    let eve = actor(&root, "acme", "eve");
    let budget = DocumentId {
        tenant_id: "acme".into(),
        id: "budget".into(),
    };
    ensure!(ken.document_permissions(&budget).can(DocumentPermission::Update).await?);
    ensure!(!eve.document_permissions(&budget).can(DocumentPermission::Read).await?);
    maya.create_member_binding()
        .scope_id("budget")
        .role_id("viewer")
        .principal_id("eve")
        .save()
        .await?;
    ensure!(eve.document_permissions(&budget).can(DocumentPermission::Read).await?);
    ensure!(!eve.document_permissions(&budget).can(DocumentPermission::Update).await?);
    let rows = ken.document().order_by(Document::id).all().await?;
    ensure!(rows.iter().all(|row| row.tenant_id == "acme"));
    ensure!(rows.iter().any(|row| row.id == "budget"));
    ensure!(!rows.iter().any(|row| row.id == "engineering"));
    println!("Ken can edit Finance's budget. Eve can read only the budget shared with her.");
    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
    }
}
seed.rs
use crate::generated::enums::{MemberRole, PrincipalKind};
use crate::{Client, actor};
use anyhow::Result;

#[allow(clippy::too_many_lines)]
pub(super) async fn run(base: &Client) -> Result<()> {
    for tenant in ["acme", "northwind", "personal"] {
        let system = base.system();
        system.create_organization().tenant_id(tenant).name(tenant).save().await?;
        for (id, role, kind, active) in [
            ("maya", MemberRole::Owner, PrincipalKind::Human, true),
            ("sana", MemberRole::SecurityAdmin, PrincipalKind::Human, true),
            ("bill", MemberRole::BillingAdmin, PrincipalKind::Human, true),
            ("ken", MemberRole::Member, PrincipalKind::Human, true),
            ("jo", MemberRole::Member, PrincipalKind::Human, true),
            ("eve", MemberRole::Member, PrincipalKind::Human, true),
            ("gia", MemberRole::Guest, PrincipalKind::Human, true),
            ("scout", MemberRole::Service, PrincipalKind::Service, true),
            ("sam", MemberRole::Member, PrincipalKind::Human, false),
        ] {
            if tenant == "personal" && id != "maya" {
                continue;
            }
            system
                .create_principal()
                .tenant_id(tenant)
                .id(id)
                .name(id)
                .kind(kind)
                .save()
                .await?;
            system
                .create_membership()
                .tenant_id(tenant)
                .principal_id(id)
                .role(role)
                .active(active)
                .save()
                .await?;
        }
        let maya = actor(base, tenant, "maya");
        for (id, read, edit, comment, delete, share, create, approve) in [
            ("viewer", true, false, false, false, false, false, false),
            ("editor", true, true, true, false, false, true, false),
            ("commenter", true, false, true, false, false, false, false),
            ("sharer", true, false, false, false, true, false, false),
            ("approver", true, false, false, false, false, false, true),
            ("manager", true, true, true, true, true, true, true),
        ] {
            maya.create_role()
                .id(id)
                .name(id)
                .can_read(read)
                .can_edit(edit)
                .can_comment(comment)
                .can_delete(delete)
                .can_share(share)
                .can_create(create)
                .can_approve(approve)
                .save()
                .await?;
        }
        maya.create_resource_scope()
            .id("finance")
            .name("Finance")
            .owner_id("maya")
            .save()
            .await?;
        maya.create_resource_scope()
            .id("budget")
            .name("Budget")
            .parent_id("finance")
            .owner_id("maya")
            .save()
            .await?;
        maya.create_resource_scope()
            .id("secret")
            .name("Restricted audit")
            .parent_id("finance")
            .owner_id("maya")
            .inherit_access(false)
            .save()
            .await?;
        maya.create_document()
            .id("budget")
            .scope_id("budget")
            .title("Budget")
            .body("original")
            .save()
            .await?;
        maya.create_document()
            .id("secret")
            .scope_id("secret")
            .title("Restricted")
            .body("private")
            .save()
            .await?;
        if tenant == "personal" {
            continue;
        }
        actor(base, tenant, "eve")
            .create_resource_scope()
            .id("engineering")
            .name("Engineering")
            .owner_id("eve")
            .save()
            .await?;
        actor(base, tenant, "eve")
            .create_document()
            .id("engineering")
            .scope_id("engineering")
            .title("Engineering")
            .save()
            .await?;
        maya.create_access_group().id("finance").name("Finance staff").save().await?;
        maya.create_group_member()
            .group_id("finance")
            .principal_id("ken")
            .save()
            .await?;
        maya.create_group_binding()
            .scope_id("finance")
            .group_id("finance")
            .role_id("editor")
            .save()
            .await?;
        for (id, target) in [
            ("jo", "finance"),
            ("scout", "finance"),
            ("sam", "finance"),
            ("gia", "secret"),
        ] {
            maya.create_member_binding()
                .scope_id(target)
                .role_id("viewer")
                .principal_id(id)
                .save()
                .await?;
        }
    }
    Ok(())
}
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))
}