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 = falseThe 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 = falseThe 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 --jsonEach 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.rslFor 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.rslEach 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