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.
Keep shared links in a trusted command
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 runRun 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))
}