Skip to content
Tutorials

Test permission expectations

Create a TOML permission fixture beside your application's RSL schema. The CLI seeds an isolated database and checks access as each declared viewer. This tutorial uses two tenants with matching local IDs to verify that sharing stays inside its tenant.

Express the expected behavior

The RSL defines Post.editor = [User] or author, viewer = editor, and delete = author. A direct editor grant lets Ken view Maya's post without letting him delete it:

version = 1

[viewers]
ken = { subject = 'User:{"tenant_id":"acme","id":"ken"}' }

[[grant]]
can = "Post.editor"
on = { tenant_id = "acme", id = "notes" }
subject = 'User:{"tenant_id":"acme","id":"ken"}'

[[check]]
name = "editor cannot delete"
viewer = "ken"
can = "Post.delete"
on = { tenant_id = "acme", id = "notes" }
expect = false

The complete schema and fixture, including the [[rows.User]] and [[rows.Post]] seed entries, are at the end of this lesson. Use RSL field names in those entries. Seed rows establish trusted database state, while the expectations run with the chosen viewer and all ordinary read and tenant guards.

If your models use @updatedAt, supply that timestamp explicitly in seed rows: trusted seed inserts do not run client-maintained timestamps. Database defaults such as @default(now()) can be omitted. See timestamp seed rules.

Test tenant boundaries and inherited access

A cross-tenant endpoint is denied even when another tenant has a matching ID:

[[check]]
name = "foreign post is denied"
viewer = "ken"
can = "Post.viewer"
on = { tenant_id = "northwind", id = "notes" }
expect = false

[[list]]
name = "comments inherit visible post access"
viewer = "ken"
model = "Comment"
can = "read"
expect = [{ tenant_id = "acme", id = "reply" }]

The complete example also checks hidden and missing posts, a foreign-tenant group claim, expired grants, missing tenant scope, anonymous viewers, and delegated access where both subjects must qualify.

Verify reverse inspection honestly

Reverse queries use a separate inspecting viewer. They return visible persisted subjects with bare claims, and report whether unenumerated claim populations remain:

[[who]]
name = "stored editors"
viewer = "maya"
can = "Post.editor"
on = { tenant_id = "acme", id = "notes" }
subject = "User"
expect = [
  { tenant_id = "acme", id = "maya" },
  { tenant_id = "acme", id = "ken" },
]
complete = false

The explicit complete = false matters: direct grants can also match trusted external group claims, which a database cannot enumerate. A wrong completeness expectation fails the test.

Run your own fixtures

rent permissions test --schema rent/schema.rsl
rent permissions test permissions/team.permissions.toml \
  --schema rent/schema.rsl --json

Each discovered file runs independently. One failed expectation does not hide later results. The command reports all executed checks and exits unsuccessfully. TOML typos and unsupported sections are rejected rather than counted as coverage.

To repeat on PostgreSQL, MySQL or MariaDB, pass an explicit empty dedicated database with --database-url. Existing application tables cause an error before seeding. See the command reference for options, expected error categories and the boundary between trusted seed state and generated-client mutation tests.

Test a circular account/workspace relationship

A fixture can include an account's nullable root-workspace pointer and that workspace's required account reference. Declare both final values normally:

rent permissions test permissions/root-workspace.permissions.toml \
  --schema rent/schema.rsl

For example, verify that a workspace manager can delete an ordinary workspace while the root workspace remains protected. Use matching local IDs across two tenants to check scope isolation. Checks and lists run after both root pointers have been restored.

The loader inserts each account with its nullable root pointer temporarily null, inserts the workspaces, then restores the pointers within the seed-row transaction. Foreign keys remain enabled. If a link is invalid, all seed rows roll back. This constructs test state. It does not exercise your application's workspace-creation command or mutation hooks. See circular seed references for the supported boundary and rejection cases.

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.

This example runs through the Rent CLI and needs no Rust application:

rent permissions test team.permissions.toml --schema schema.rsl

Each invocation uses an isolated in-memory SQLite database.

schema.rsl
tenancy {
  scope = "required"
  field = "tenant_id"
}

access {
  subjects   = [User]
  delegation = "intersect"
}

mixin FixtureTimestamps {
  updated_at   DateTime   @updatedAt  @map("last_updated_at")
  defaulted_at DateTime   @default(now())  @updatedAt
  optional_at  DateTime?  @updatedAt
}

model User {
  use FixtureTimestamps
  tenant_id String  @rent.type(mysql: "varchar(64)")
  id        String  @rent.type(mysql: "varchar(64)")

  @@id([tenant_id, id])
}

model Post {
  tenant_id String  @rent.type(mysql: "varchar(64)")
  id        String  @rent.type(mysql: "varchar(64)")
  author_id String  @rent.type(mysql: "varchar(64)")
  author    User    @relation(fields: [tenant_id, author_id], references: [tenant_id, id])  @rent.creator

  @@id([tenant_id, id])
}

model Comment {
  tenant_id String  @rent.type(mysql: "varchar(64)")
  id        String  @rent.type(mysql: "varchar(64)")
  post_id   String  @rent.type(mysql: "varchar(64)")
  post      Post    @relation(fields: [tenant_id, post_id], references: [tenant_id, id])

  @@id([tenant_id, id])
}

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

permissions Post {
  editor        = [User] or author
  viewer        = editor
  read          = viewer
  create        = anyone
  update        = editor
  delete        = author
  manage_grants = author
}

permissions Comment {
  read                   = post.viewer
  create, update, delete = post.editor
}
team.permissions.toml
version = 1

[viewers]
anonymous = {}
anonymous_acme = { tenant = "acme" }
maya = { subject = 'User:{"tenant_id":"acme","id":"maya"}' }
ken = { subject = 'User:{"tenant_id":"acme","id":"ken"}' }
eve = { subject = 'User:{"tenant_id":"acme","id":"eve"}' }
foreign_group = { subject = 'User:{"tenant_id":"acme","id":"maya"}', groups = ['User:{"tenant_id":"northwind","id":"ken"}'] }
delegated = { subject = 'User:{"tenant_id":"acme","id":"ken"}', on_behalf_of = 'User:{"tenant_id":"acme","id":"eve"}' }

[[rows.User]]
updated_at = "2026-01-01T00:00:00Z"
tenant_id = "acme"
id = "maya"
[[rows.User]]
updated_at = "2026-01-01T00:00:00Z"
tenant_id = "acme"
id = "ken"
[[rows.User]]
updated_at = "2026-01-01T00:00:00Z"
tenant_id = "acme"
id = "eve"
[[rows.User]]
updated_at = "2026-01-01T00:00:00Z"
tenant_id = "northwind"
id = "maya"
[[rows.User]]
updated_at = "2026-01-01T00:00:00Z"
tenant_id = "northwind"
id = "ken"
[[rows.Post]]
tenant_id = "acme"
id = "notes"
author_id = "maya"
[[rows.Post]]
tenant_id = "northwind"
id = "notes"
author_id = "maya"
[[rows.Comment]]
tenant_id = "acme"
id = "reply"
post_id = "notes"

[[grant]]
can = "Post.editor"
on = { tenant_id = "acme", id = "notes" }
subject = 'User:{"tenant_id":"acme","id":"ken"}'

[[grant]]
can = "Post.editor"
on = { tenant_id = "acme", id = "notes" }
subject = 'User:{"tenant_id":"acme","id":"eve"}'
expires_at = "2000-01-01T00:00:00Z"

[[check]]
name = "missing tenant is an error"
viewer = "anonymous"
can = "Post.viewer"
on = { tenant_id = "acme", id = "notes" }
error = "missing_tenant"
[[check]]
name = "anonymous does not inherit grants"
viewer = "anonymous_acme"
can = "Post.viewer"
on = { tenant_id = "acme", id = "notes" }
expect = false

[[check]]
name = "editor can read"
viewer = "ken"
can = "Post.viewer"
on = { tenant_id = "acme", id = "notes" }
expect = true
[[check]]
name = "editor cannot delete"
viewer = "ken"
can = "Post.delete"
on = { tenant_id = "acme", id = "notes" }
expect = false
[[check]]
name = "hidden is denied"
viewer = "eve"
can = "Post.viewer"
on = { tenant_id = "acme", id = "notes" }
expect = false
[[check]]
name = "missing is denied"
viewer = "ken"
can = "Post.viewer"
on = { tenant_id = "acme", id = "missing" }
expect = false
[[check]]
name = "cross tenant is denied"
viewer = "ken"
can = "Post.viewer"
on = { tenant_id = "northwind", id = "notes" }
expect = false
[[check]]
name = "foreign group is rejected"
viewer = "foreign_group"
can = "Post.viewer"
on = { tenant_id = "acme", id = "notes" }
error = "tenant_mismatch"
[[check]]
name = "delegated claims intersect"
viewer = "delegated"
can = "Post.viewer"
on = { tenant_id = "acme", id = "notes" }
expect = false
[[list]]
name = "visible posts stay in tenant"
viewer = "ken"
model = "Post"
can = "viewer"
expect = [{ tenant_id = "acme", id = "notes" }]
[[list]]
name = "comment inherits post access"
viewer = "ken"
model = "Comment"
can = "read"
expect = [{ tenant_id = "acme", id = "reply" }]
[[who]]
name = "stored editors"
viewer = "maya"
can = "Post.editor"
on = { tenant_id = "acme", id = "notes" }
subject = "User"
expect = [{ tenant_id = "acme", id = "maya" }, { tenant_id = "acme", id = "ken" }]
complete = false