Skip to content
Tutorials

Teams, sharing and delegated agents

Maya creates a Design team, invites Ken to work on projects, and lets Scout, an agent, use a particular connection. Ken can help with the work without owning the team. Scout can use a tool without gaining the right to edit the agent that uses it.

These are separate relationships. RSL lets you name each kind of access and connect it to the relevant models.

Make the creator the first owner

In this application, Team.creator identifies the person who created the team, and Team.people contains membership rows with a role and a principal relationship:

permissions Team {
  owner   = creator or people(role: OWNER).principal
  manager = owner or people(role: MANAGE).principal
  worker  = manager or people(role: WORK).principal
  reader  = worker or people(role: READ).principal

  read   = reader
  create = anyone(kind: PERSON)
  update = manager
  delete = owner
}

Read from the top: owners are also managers, managers are also workers, and workers are also readers. The creator can start managing the team before another membership row exists. kind: PERSON is a condition on this application's principal model.

With the creator relationship marked @rent.creator, Maya can create her team and add Ken using ordinary writes:

maya.create_team().id("design").name("Design").save().await?;

maya.create_team_person()
    .team_id("design")
    .principal_id("ken")
    .role(TeamRole::Work)
    .save()
    .await?;

Protect membership changes with the team's manager rule:

permissions TeamPerson {
  read                   = team.reader
  create, update, delete = team.manager
}

Ken can work on the team, but can't promote himself to OWNER through an ordinary membership update.

Share the resource, not the whole application

A canvas can have its own owner, person shares, and team shares. Giving Ken edit access to one canvas need not give him deletion or sharing authority. Giving the team read access lets team membership determine who benefits.

A restricted project can similarly require explicit membership. Its tasks can follow that project's reader and worker rules, so making a project private also protects the work underneath it. Use inherited access when resources belong to a hierarchy.

Treat agents as actors with their own permissions

A principal can represent a person or an agent. A permission to run Scout doesn't have to include editing Scout's configuration or adding new runners. A connection can grant access to Scout without granting the same access to every person who runs it.

When Scout acts on behalf of Maya, carry their identities separately:

let delegated = base.clone().with_viewer(
    Viewer::of(Principal::subject("scout".to_owned()))
        .on_behalf_of(SubjectClaims::of(Principal::subject("maya".to_owned()))),
);

Your application authenticates Scout and decides whether it may represent Maya before creating this context. The configured delegation mode decides how their permissions combine: intersect requires both. inherit permits either. Choose that policy deliberately for your tools rather than relying on an agent to filter private data. See delegation for the claim API.

A guessed canvas ID isn't proof that someone has a valid sharing link. Your application must verify the link, its target, expiry, and revocation before creating an assignment for the authenticated visitor.

For example, ordinary CanvasVisitor creation can be denied while a narrow trusted link-redemption service establishes the visitor relationship after verification. That relationship can supply read access to the canvas without exposing the private visitor record or granting editing rights.

Let sessions follow their context

A conversation may belong to a project, a canvas, or another session. Its rules can follow those relationships:

permissions Session {
  writer = owner or project.worker or canvas.commenter or parent.writer
  reader = writer or runs_as or project.member or canvas.viewer or parent.reader

  read   = reader
  create = anyone
  update = writer
  delete = owner or project.team.manager
}

Each relationship and rule here belongs to this application's schema. A participant can read without necessarily being allowed to write. Sharing a parent session can provide access to a child, and removing that route removes the inherited access too.

This collaboration policy can be used without tenancy. For several organizations in one database, combine it with tenant boundaries.

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-30-collaboration-permissions"
version = "0.1.0"
edition = "2024"

[workspace]

[[bin]]
name = "rent-lesson-30-collaboration-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
access {
  subjects   = [Principal]
  delegation = "inherit"
  depth      = 16
}

enum PrincipalKind {
  PERSON
  AGENT
  MEMBER
  RUN
  GROUP
}

model Principal {
  id   String         @id
  kind PrincipalKind
  name String
}

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

// Teams
enum TeamRole {
  READ
  WORK
  MANAGE
  OWNER
}

model Team {
  id         String        @id
  name       String
  creator_id String
  creator    Principal     @relation(fields: [creator_id], references: [id])  @rent.creator
  people     TeamPerson[]
  projects   Project[]
}

model TeamPerson {
  team_id      String
  principal_id String
  role         TeamRole
  added_by_id  String
  team         Team       @relation(fields: [team_id], references: [id], onDelete: Cascade)
  principal    Principal  @relation("TeamPersonPrincipal", fields: [principal_id], references: [id])
  added_by     Principal  @relation("TeamPersonAddedBy", fields: [added_by_id], references: [id])  @rent.creator

  @@id([team_id, principal_id])
}

permissions Team {
  // The stamped creator can add the first membership row.
  owner   = creator or people(role: OWNER).principal
  manager = owner or people(role: MANAGE).principal
  worker  = manager or people(role: WORK).principal
  reader  = worker or people(role: READ).principal
  read    = reader or admin
  create  = anyone(kind: PERSON)
  update  = manager
  delete  = owner
}

permissions TeamPerson {
  read                   = team.reader
  create, update, delete = team.manager
}

// Projects
model Project {
  id         String           @id
  team_id    String
  restricted Boolean          @default(false)
  team       Team             @relation(fields: [team_id], references: [id])
  viewers    ProjectViewer[]
  canvases   Canvas[]
  tasks      ProjectTask[]
}

model ProjectViewer {
  project_id   String
  principal_id String
  project      Project    @relation(fields: [project_id], references: [id], onDelete: Cascade)
  principal    Principal  @relation(fields: [principal_id], references: [id])

  @@id([project_id, principal_id])
}

permissions Project {
  member         = team.manager or team.reader when restricted: false or viewers and team.reader
  worker         = member and team.worker
  read           = member or admin
  create         = team.worker
  update, delete = team.manager
}

permissions ProjectViewer {
  read                   = project.member
  create, update, delete = project.team.manager
}

model ProjectTask {
  id         String         @id
  project_id String
  parent_id  String?
  project    Project        @relation(fields: [project_id], references: [id], onDelete: Cascade)
  parent     ProjectTask?   @relation("Subtasks", fields: [parent_id], references: [id])
  subtasks   ProjectTask[]  @relation("Subtasks")
}

permissions ProjectTask {
  read                   = project.member or admin
  create, update, delete = project.worker
}

// Canvases
enum ShareAccess {
  VIEW
  COMMENT
  EDIT
}

enum GeneralAccess {
  INVITED
  WORKSPACE
  LINK
}

model Canvas {
  id         String               @id
  owner_id   String
  project_id String?
  general    GeneralAccess        @default(INVITED)
  owner      Principal            @relation(fields: [owner_id], references: [id])  @rent.creator
  project    Project?             @relation(fields: [project_id], references: [id])
  people     CanvasPersonShare[]
  teams      CanvasTeamShare[]
  visitors   CanvasVisitor[]
  threads    CanvasThread[]
}

model CanvasPersonShare {
  canvas_id    String
  principal_id String
  access       ShareAccess
  expires_at   DateTime?    @rent.expires
  canvas       Canvas       @relation(fields: [canvas_id], references: [id], onDelete: Cascade)
  principal    Principal    @relation(fields: [principal_id], references: [id])

  @@id([canvas_id, principal_id])
}

model CanvasTeamShare {
  canvas_id String
  team_id   String
  access    ShareAccess
  canvas    Canvas       @relation(fields: [canvas_id], references: [id], onDelete: Cascade)
  team      Team         @relation(fields: [team_id], references: [id], onDelete: Cascade)

  @@id([canvas_id, team_id])
}

model CanvasVisitor {
  canvas_id    String
  principal_id String
  canvas       Canvas     @relation(fields: [canvas_id], references: [id], onDelete: Cascade)
  principal    Principal  @relation(fields: [principal_id], references: [id])

  @@id([canvas_id, principal_id])
}

permissions Canvas {
  editor    = owner or people(access: EDIT) or teams(access: EDIT).team.worker or project.worker
  commenter = editor or people(access: COMMENT) or teams(access: [COMMENT, EDIT]).team.worker
  viewer    = commenter
    or people(access: VIEW)
    or teams.team.reader
    or project.member
    or anyone(kind: PERSON) when general: WORKSPACE
    or visitors when general: LINK
  read      = viewer or admin
  create    = project.worker or anyone(kind: PERSON) when project_id: null
  update    = editor
  delete    = owner
}

permissions CanvasPersonShare {
  read                   = canvas.viewer
  create, update, delete = canvas.owner
}

permissions CanvasTeamShare {
  read                   = canvas.viewer
  create, update, delete = canvas.owner
}

permissions CanvasVisitor {
  read           = canvas.owner
  // Link possession must be verified by trusted application code before insertion.
  create         = nobody
  update, delete = nobody
}

model CanvasThread {
  id        String  @id
  canvas_id String
  canvas    Canvas  @relation(fields: [canvas_id], references: [id], onDelete: Cascade)
}

permissions CanvasThread {
  read           = canvas.viewer
  create, update = canvas.commenter
  delete         = canvas.editor
}

// Agents, connections and sessions
enum Visibility {
  PRIVATE
  TEAM
  WORKSPACE
}

model Agent {
  key          String         @id
  maker_id     String
  principal_id String         @unique
  team_id      String?
  visibility   Visibility     @default(WORKSPACE)
  maker        Principal      @relation("AgentMaker", fields: [maker_id], references: [id])  @rent.creator
  principal    Principal      @relation("AgentPrincipal", fields: [principal_id], references: [id])
  team         Team?          @relation(fields: [team_id], references: [id])
  runners      AgentRunner[]
}

model AgentRunner {
  agent_key    String
  principal_id String
  agent        Agent      @relation(fields: [agent_key], references: [key], onDelete: Cascade)
  principal    Principal  @relation(fields: [principal_id], references: [id])

  @@id([agent_key, principal_id])
}

permissions Agent {
  editor = maker or team.manager
  runner = editor
    or runners
    or team.worker when visibility: TEAM
    or anyone(kind: PERSON) when visibility: WORKSPACE
  read   = runner or admin
  create = anyone(kind: PERSON)
  update = editor
  delete = maker or admin
}

permissions AgentRunner {
  read                   = agent.runner
  create, update, delete = agent.editor
}

enum AccountKind {
  PERSONAL
  SHARED
  COMPANY
}

model Connection {
  id             String                  @id
  account        AccountKind
  owner_id       String?
  owner_team_id  String?
  workspace_wide Boolean                 @default(false)
  owner          Principal?              @relation(fields: [owner_id], references: [id])
  owner_team     Team?                   @relation(fields: [owner_team_id], references: [id])
  team_grants    ConnectionTeamGrant[]
  agent_grants   ConnectionAgentGrant[]
}

model ConnectionTeamGrant {
  connection_id String
  team_id       String
  connection    Connection  @relation(fields: [connection_id], references: [id], onDelete: Cascade)
  team          Team        @relation(fields: [team_id], references: [id], onDelete: Cascade)

  @@id([connection_id, team_id])
}

model ConnectionAgentGrant {
  connection_id String
  agent_key     String
  connection    Connection  @relation(fields: [connection_id], references: [id], onDelete: Cascade)
  agent         Agent       @relation(fields: [agent_key], references: [key], onDelete: Cascade)

  @@id([connection_id, agent_key])
}

permissions Connection {
  manager        = owner or owner_team.manager
  user           = manager
    or anyone when workspace_wide: true
    or anyone when account: COMPANY
    or team_grants.team.worker
    or agent_grants.agent.principal
  read           = user or admin
  create         = anyone(kind: PERSON)
  update, delete = manager or admin
}

model Session {
  id         String      @id
  owner_id   String?
  project_id String?
  canvas_id  String?
  runs_as_id String?
  parent_id  String?
  owner      Principal?  @relation("SessionOwner", fields: [owner_id], references: [id])
  project    Project?    @relation(fields: [project_id], references: [id])
  canvas     Canvas?     @relation(fields: [canvas_id], references: [id])
  runs_as    Principal?  @relation("SessionRunsAs", fields: [runs_as_id], references: [id])
  parent     Session?    @relation("Delegated", fields: [parent_id], references: [id])
  children   Session[]   @relation("Delegated")
  messages   Message[]
}

permissions Session {
  writer = owner or project.worker or canvas.commenter or parent.writer
  reader = writer or runs_as or project.member or canvas.viewer or parent.reader
  read   = reader or admin
  create = anyone
  update = writer
  delete = owner or project.team.manager
}

model Message {
  session_id String
  seq        BigInt
  session    Session  @relation(fields: [session_id], references: [id], onDelete: Cascade)

  @@id([session_id, seq])
}

permissions Message {
  read           = session.reader or admin
  create         = session.writer
  update, delete = nobody
}

permissions ConnectionTeamGrant {
  read                   = connection.user
  create, update, delete = connection.manager
}

permissions ConnectionAgentGrant {
  read                   = connection.user
  create, update, delete = connection.manager
}
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::{PrincipalKind, TeamRole},
    principal::Principal,
    team::TeamPermission,
};
async fn example(url: &str) -> Result<()> {
    let root = Client::connect(url).await?;
    for (id, kind) in [
        ("maya", PrincipalKind::Person),
        ("ken", PrincipalKind::Person),
        ("scout", PrincipalKind::Agent),
    ] {
        root.system().create_principal().id(id).name(id).kind(kind).save().await?;
    }
    let maya = root.clone().with_viewer(Viewer::of(Principal::subject("maya".into())));
    let ken = root.with_viewer(Viewer::of(Principal::subject("ken".into())));
    maya.create_team().id("design").name("Design").save().await?;
    maya.create_team_person()
        .team_id("design")
        .principal_id("ken")
        .role(TeamRole::Work)
        .save()
        .await?;
    ensure!(ken.team_permissions(&"design".to_owned()).can(TeamPermission::Worker).await?);
    ensure!(!ken.team_permissions(&"design".to_owned()).can(TeamPermission::Owner).await?);
    ensure!(
        ken.update_team_person()
            .team_id_eq("design")
            .principal_id_eq("ken")
            .role(TeamRole::Owner)
            .save()
            .await?
            == 0
    );
    println!("Ken can work in Design without promoting himself to owner.");
    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))
}