Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/spicedb/modeling/_meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export default {
"composable-schemas": "Composable Schemas (Preview)",
"representing-users": "Representing Users",
"validation-testing-debugging": "Validation, Testing, Debugging",
"recursion-and-max-depth": "Recursion & Max Depth",
"recursion-and-max-depth": "Cyclical Relationships",
"protecting-a-list-endpoint": "Protecting a List Endpoint",
"migrating-schema": "Migrating a Schema",
"access-control-management": "Access Control Management",
Expand Down
197 changes: 116 additions & 81 deletions app/spicedb/modeling/recursion-and-max-depth/page.mdx
Original file line number Diff line number Diff line change
@@ -1,30 +1,68 @@
# Recursion and Max Depth
# Cyclical Relationships and Traversal Limits

Permissions questions in SpiceDB are answered by traversing the **tree** constructed from the graph formed
by combining the [schema] (structure) and [relationships] (data).
SpiceDB answers permissions questions by traversing a **tree** constructed from your [schema] (structure) and [relationships] (data).

A `CheckPermission` request will, for example, traverse starting from the resource+permission requested,
along any referenced permissions and relations, until the subject is found or maximum depth is
reached.
When you call `CheckPermission`, SpiceDB starts at the resource and permission you specified, then walks through relations and permissions until it either finds the subject or determines the subject doesn't have access.

[schema]: /spicedb/concepts/schema
[relationships]: /spicedb/concepts/relationships

## Max Depth
## How Traversal Works

In order to prevent requests from traversing without bounds, SpiceDB comes with a defaults to a depth of
`50`, after which computation is halted and an error is returned to the caller.
Consider this simple example:

This max depth is configurable via the `--dispatch-max-depth` flag.
```
┌─────────────────────┐
│ document:readme │
│ permission: view │
└─────────┬───────────┘
│ viewer relation
┌─────────────────────┐
│ group:engineering │
│ permission: member │
└─────────┬───────────┘
│ member relation
┌─────────────────────┐
│ user:alice │
└─────────────────────┘
```

When checking if `user:alice` can `view` `document:readme`, SpiceDB traverses:

1. `document:readme#view` → follows `viewer` relation
2. `group:engineering#member` → follows `member` relation
3. Found `user:alice` → returns **allowed**

Each arrow represents one "hop" in the traversal.
This tree has a **depth of 3** (three nodes visited).

## Traversal Depth Limit

To prevent unbounded traversal, SpiceDB enforces a maximum depth limit.
By default, this limit is **50 hops**.
If a traversal exceeds this limit, SpiceDB returns an error rather than continuing indefinitely.

You can configure this limit with the `--dispatch-max-depth` flag:

```sh
spicedb serve --dispatch-max-depth=100
```

Most schemas work well within the default limit.
You typically only need to increase it if you have legitimately deep hierarchies (like deeply nested folder structures).

## Recursion in Relationships
## Cyclical Relationships (Cycles)

As a result of expecting the permissions graph to be a **tree**, SpiceDB _does not_ support recursive data dependencies that result in operations
(such as `CheckPermission`) visiting the _same_ object more than once.
A **cycle** occurs when traversing the permissions tree leads back to an object that was already visited.
SpiceDB does not support cyclical relationships because the permissions graph must be a [tree], not a graph with loops.

### Example
[tree]: https://zanzibar.tech/2SMVg4W_Wx:N:k

The following is an example of an **unsupported** nesting of groups:
### Example of a Cycle

Consider this schema for nested groups:

```zed
definition user {}
Expand All @@ -39,83 +77,72 @@ definition resource {
}
```

and relationships:
With these relationships:

```
resource:someresource#viewer@group:firstgroup#member
group:firstgroup#member@group:secondgroup#member
group:secondgroup#member@group:thirdgroup#member
group:thirdgroup#member@group:firstgroup#member
group:thirdgroup#member@group:firstgroup#member ← creates a cycle!
```

When computing a permission answer for `resource:someresource`, SpiceDB will attempt this walk: `resource:someresource#viewer`
-> `group:firstgroup#member` -> `group:secondgroup#member` -> `group:thirdgroup#member` ->
`group:firstgroup#member` -> ..., causing a cycle.

## Common Questions

### Why doesn't SpiceDB simply support tracking the objects it has walked?

1. Nested recursive "sets" have unclear semantics.

2. Undesirable overhead.

#### Nested sets have semantics issues
Visually, this creates a loop:

[Zanzibar] and ReBAC in general operate on _sets_: when a permission check is made, SpiceDB is
answering whether the requested subject is a member of the _set_ formed of all subjects that are
visited by walking the permissions tree.

[Zanzibar]: https://zanzibar.tech

The question becomes: if a group's members contains the members of _itself_, is that legal within
a set?
Much academic literature has been written about this topic (which we won't repeat here),
but the very question raises whether allowing such an approach is semantically valid.

As a real example, imagine the following schema and relationships:
```
┌──────────────────────┐
│ resource:someresource│
│ permission: view │
└──────────┬───────────┘
│ viewer
┌──────────────────────┐
│ group:firstgroup │◄─────────────────┐
│ permission: member │ │
└──────────┬───────────┘ │
│ member │
▼ │
┌──────────────────────┐ │
│ group:secondgroup │ │ member
│ permission: member │ │ (cycle!)
└──────────┬───────────┘ │
│ member │
▼ │
┌──────────────────────┐ │
│ group:thirdgroup │──────────────────┘
│ permission: member │
└──────────────────────┘
```

```zed
definition user {}
When SpiceDB traverses this, it walks:
`resource:someresource#viewer` → `group:firstgroup#member` → `group:secondgroup#member` → `group:thirdgroup#member` → `group:firstgroup#member` → ...

definition group {
relation direct_member: user | group#member
relation banned: user | group#member
permission member = direct_member - banned
}
```
The traversal returns to `group:firstgroup#member`, creating an infinite loop.

```
group:firstgroup#direct_member@group:secondgroup#member
group:firstgroup#banned@group:bannedgroup#member
group:secondgroup#direct_member@user:tom
group:bannedgroup#direct_member@group:firstgroup#member
```
### How SpiceDB Handles Cycles

As we see above,`user:tom` is a `direct_member` of `secondgroup`, which makes him a member
of `firstgroup` -> which implies he's a member of `bannedgroup` -> which implies he's _not_
a member of `firstgroup` -> thus making him no longer `banned` -> (logical inconsistency)
SpiceDB does not have a dedicated cycle detector.
Instead, when a cycle exists, the traversal continues looping until it hits the **maximum depth limit**, then returns an error.
This same error occurs whether the cause is a cycle or simply a very deep (but acyclic) hierarchy.

Thus, to prevent the above issue from occurring, Zanzibar and other ReBAC implementations such
as SpiceDB assume the permissions graph is a [tree].
**Why not track visited objects?**
SpiceDB intentionally avoids tracking visited objects for two reasons:

[tree]: https://zanzibar.tech/2SMVg4W_Wx:N:k
1. **Semantic problems with self-referential sets**: When a group's members include itself, it creates logical paradoxes.
For example, if a user is a member through a cycle but also banned through the same cycle, is the user a member or not?

#### Overhead
2. **Performance overhead**: Tracking every visited object would require significant memory and network overhead, especially in distributed deployments.

From a practical perspective, tracking of visited objects when computing `CheckPermission` and
other permissions queries results in having significant amount of overhead over the wire and in
memory to track the full set of encountered objects and check for duplicates.
## Common Questions

### What do I do about a max depth error on CheckPermission?
### What do I do about a max depth error?

If you've received an error like:
If you see an error like:

```
the check request has exceeded the allowable maximum depth of 50: this usually indicates a recursive or too deep data dependency. Try running zed with --explain to see the dependency
```

Run `zed --explain` with the parameters of the check to show whether the issue is due to recursion or because the tree is simply too deep:
Use `zed permission check` with `--explain` to visualize the traversal path:

```sh
zed permission check resource:someresource view user:someuser --explain
Expand All @@ -130,20 +157,28 @@ zed permission check resource:someresource view user:someuser --explain
└── ! group:firstgroup member (cycle) (3.194125ms)
```

### Why did my check work with recursion?
The output shows each hop in the traversal.
If you see `(cycle)` in the output, you have a cyclical relationship.
If there's no cycle, your hierarchy is simply deeper than the limit allows.

### Why did my check succeed despite having a cycle?

SpiceDB automatically short-circuits `CheckPermission` operations when the target subject has been
found.
SpiceDB short-circuits `CheckPermission` when it finds the subject.
If the subject is found before the traversal hits the cycle or exceeds the depth limit, the check succeeds.

If the subject was found before the maximum depth was hit, then the operation will complete
successfully.
_However_, if the subject was not found, SpiceDB will continue walking, and ultimately return
the error you saw.
However, if the subject is **not** found, the traversal continues until it hits the depth limit and returns an error.

### How do I check for a possible recursion when writing a relationship?
### How do I prevent cycles when writing relationships?

Before writing a relationship that could create a cycle, use `CheckPermission` to verify the relationship won't create a loop.

For example, before writing `group:parent#member@group:child#member`, check if the parent is already reachable from the child:

```sh
zed permission check group:child member group:parent
```

Use the `CheckPermission` API to check if the subject contains the resource.
If this check returns **allowed**, writing the relationship would create a cycle.
If it returns **denied**, the relationship is safe to write.

For example, if writing the relationship `group:someparent#member@group:somechild#member` a check
can be made for `group:somechild#member@group:someparent#member`: if the _parent_ has permission
_on the child_, then the addition of this relationship will cause a recursion.
This pattern works because: if the parent already has permission on the child, making the child a member of the parent creates a circular dependency.
2 changes: 1 addition & 1 deletion app/spicedb/modeling/validation-testing-debugging/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ true

This command will also highlight which parts of the traversal were cached and if a [cycle] is detected.

[cycle]: ./recursion-and-max-depth#recursion-in-relationships
[cycle]: ./recursion-and-max-depth#cyclical-relationships-cycles
[tracing header]: #checkpermission-tracing-header

## SpiceDB GitHub Actions
Expand Down
Loading