django-spicedb
Declarative, relationship-based access control (ReBAC) for Django, backed by SpiceDB. Define your authorization model once in Python; let Django enforce it everywhere.
Features
- Model-centric configuration - Define relations and permissions directly on Django models via
RebacMeta - Automatic tuple sync - FK and M2M changes automatically sync to SpiceDB via signals
- Permission inheritance - Build hierarchies with
parent->permissionexpressions - Group-based access - Role-based group membership (member/manager) out of the box
- Query integration - Filter querysets by permission with
.accessible_by(user, 'view') - FK change tracking - Automatically cleans up stale tuples when relationships change
Installation
pip install django-spicedb
Add to INSTALLED_APPS:
INSTALLED_APPS = [
# ...
'django_spicedb',
]
Configure SpiceDB connection:
REBAC = {
'adapter': {
'endpoint': 'localhost:50051',
'token': 'your-spicedb-token',
'insecure': True, # For local development
},
}
Quickstart
This walkthrough models a tiny system and shows the thinking process you'll repeat in real apps: rules → relations → permissions → code.
0. Start SpiceDB
docker run -d --name spicedb -p 50051:50051 \
authzed/spicedb serve --grpc-preshared-key devkey
1. Write the rules in plain English
Pick a concrete example. We'll use folders and documents:
- A folder can have a parent folder.
- A document has an owner and can live in a folder.
- Owners can view/edit their own items.
- If you can view/edit a parent folder, you can view/edit its children.
If you can explain the rule to a teammate, you can model it.
2. Turn rules into relations and permissions
Think in two layers:
- Relations: direct links like "document.owner" or "folder.parent"
- Permissions: expressions like "view = owner + parent->view"
For our example:
- Relations:
owner,parent - Permissions:
view = owner + parent->view,edit = owner + parent->edit
3. Implement models with RebacMeta
Now we express that mapping in Django models.
from django.db import models
from django_spicedb.models import RebacModel
from django_spicedb.integrations.orm import RebacManager
class Folder(RebacModel):
name = models.CharField(max_length=255)
owner = models.ForeignKey('auth.User', on_delete=models.CASCADE)
parent = models.ForeignKey('self', null=True, blank=True, on_delete=models.CASCADE)
objects = RebacManager()
class RebacMeta:
relations = {
'owner': 'owner',
'parent': 'parent',
}
permissions = {
'view': 'owner + parent->view',
'edit': 'owner + parent->edit',
}
class Document(RebacModel):
title = models.CharField(max_length=255)
owner = models.ForeignKey('auth.User', on_delete=models.CASCADE)
folder = models.ForeignKey('Folder', on_delete=models.CASCADE, null=True)
objects = RebacManager()
class RebacMeta:
relations = {
'owner': 'owner',
'parent': 'folder',
}
permissions = {
'view': 'owner + parent->view',
'edit': 'owner + parent->edit',
}
4. Publish schema and backfill tuples
Schema tells SpiceDB what the types and permissions look like. Backfill creates the initial tuples from existing rows.
python manage.py publish_rebac_schema
python manage.py rebac_backfill
5. Use permissions in code
Now you can ask SpiceDB whether a user can access an object, or filter querysets by permission.
from django_spicedb.runtime import can
if can(request.user, 'view', document):
# User can view this document
documents = Document.objects.accessible_by(request.user, 'view')
Group-Based Access Control
For team/department-based permissions, use the Group pattern:
from django.db import models
from django_spicedb.models import RebacModel
from django_spicedb.integrations.orm import RebacManager
class Group(RebacModel):
"""A group with role-based membership."""
name = models.CharField(max_length=255)
objects = RebacManager()
class RebacMeta:
type_name = 'group'
relations = {
# Manual relations - no field binding, synced via GroupMembership
'member': {'subject': 'user'},
'manager': {'subject': 'user'},
}
permissions = {
'view': 'member + manager',
'manage': 'manager',
}
class GroupMembership(models.Model):
"""Through table for group membership with roles."""
ROLE_MEMBER = 'member'
ROLE_MANAGER = 'manager'
ROLE_CHOICES = [
(ROLE_MEMBER, 'Member'),
(ROLE_MANAGER, 'Manager'),
]
group = models.ForeignKey(Group, on_delete=models.CASCADE)
user = models.ForeignKey('auth.User', on_delete=models.CASCADE)
role = models.CharField(max_length=32, choices=ROLE_CHOICES, default=ROLE_MEMBER)
class Meta:
constraints = [
models.UniqueConstraint(fields=('group', 'user'), name='unique_membership'),
]
class Verification(RebacModel):
"""A resource that inherits permissions from its parent group."""
title = models.CharField(max_length=255)
owner = models.ForeignKey('auth.User', on_delete=models.CASCADE)
group = models.ForeignKey(Group, on_delete=models.CASCADE)
objects = RebacManager()
class RebacMeta:
type_name = 'verification'
relations = {
'owner': 'owner',
'parent': 'group',
}
permissions = {
'view': 'owner + parent->view', # Owner OR group members
'manage': 'owner + parent->manage', # Owner OR group managers
}
Then create signal handlers for GroupMembership to sync tuples:
# signals.py
from django.db import transaction
from django.db.models.signals import post_save, post_delete
from django_spicedb.adapters import factory
from django_spicedb.adapters.base import TupleKey, TupleWrite
def handle_membership_save(sender, instance, **kwargs):
def do_sync():
factory.get_adapter().write_tuples([
TupleWrite(key=TupleKey(
object=f'group:{instance.group_id}',
relation=instance.role,
subject=f'user:{instance.user_id}',
))
])
transaction.on_commit(do_sync)
def handle_membership_delete(sender, instance, **kwargs):
def do_delete():
factory.get_adapter().delete_tuples([
TupleKey(
object=f'group:{instance.group_id}',
relation=instance.role,
subject=f'user:{instance.user_id}',
)
])
transaction.on_commit(do_delete)
post_save.connect(handle_membership_save, sender=GroupMembership)
post_delete.connect(handle_membership_delete, sender=GroupMembership)
Now permissions flow naturally:
- Group members can view all verifications in their group
- Group managers can view and manage all verifications in their group
- Owners always have full access to their own verifications
RebacMeta Reference
Relations
Map Django fields to SpiceDB relations:
class RebacMeta:
relations = {
# FK binding - field name maps to relation
'owner': 'owner_field',
# M2M binding - field name maps to relation
'member': 'members_field',
# Manual relation - no field, synced manually
'manager': {'subject': 'user'},
# Parent relation for hierarchy
'parent': 'parent_folder',
}
Permissions
SpiceDB permission expressions:
class RebacMeta:
permissions = {
'view': 'owner + member', # OR: owner or member
'edit': 'owner', # Direct relation
'admin': 'owner + parent->admin', # Inherited from parent
'manage': 'manager + parent->manage',
}
Binding Kinds
Bindings are auto-inferred from field types:
- FK fields →
kind: 'fk'- Usesfield_idcache, tracks changes - M2M fields →
kind: 'm2m'- Syncs onpost_add,post_remove,post_clear - Manual →
{'subject': 'type'}- No auto-sync, handle via signals
How It Works
- Schema Generation:
RebacMetaon models compiles to SpiceDB schema DSL - Tuple Sync: Django signals (
post_save,post_delete,m2m_changed) write/delete tuples - FK Tracking:
pre_savecaptures old FK values;post_savedeletes stale tuples - Transaction Safety: All SpiceDB writes happen in
transaction.on_commit() - Permission Checks:
can()and.accessible_by()query SpiceDB via gRPC
Read-After-Write Freshness
SpiceDB's default minimize_latency read consistency hits a ~5s dispatcher cache, so a grant() followed immediately by a can() in the same request — or the next request after a redirect — can return a stale False. django-spicedb fixes this automatically by propagating the ZedToken returned from every write.
Add WriteTokenMiddleware to get both same-request and cross-request freshness:
# settings.py
INSTALLED_APPS = [
# ...
"django.contrib.sessions", # REQUIRED by WriteTokenMiddleware
]
MIDDLEWARE = [
# ...
"django.contrib.sessions.middleware.SessionMiddleware",
# MUST be listed *below* SessionMiddleware.
"django_spicedb.middleware.WriteTokenMiddleware",
]
The middleware persists the last-write token in request.session so that a form POST that calls obj.grant(user, "creator") and redirects to a detail view sees can(user, "view", obj) return True on the next request — no race against SpiceDB's dispatcher cache.
Pure-API backends (DRF + JWT, no Django sessions): don't install this middleware. Same-request freshness still works via the contextvar that PermissionEvaluator reads directly.
Full details, low-level API, and opt-out: docs/permissions.md → Read-After-Write Freshness.
Management Commands
# Publish schema to SpiceDB
python manage.py publish_rebac_schema
# Backfill tuples from existing Django data
python manage.py rebac_backfill
# Reconcile tuples (re-writes all expected tuples, idempotent)
python manage.py rebac_reconcile --fix
# Export policy to YAML (requires pyyaml)
pip install django-spicedb[yaml]
python manage.py export_rebac_policy
DRF Integration
Requires djangorestframework:
pip install django-spicedb[drf]
Object-Level Permissions
from rest_framework.viewsets import ModelViewSet
from django_spicedb.drf import ReBACPermission
class DocumentViewSet(ModelViewSet):
queryset = Document.objects.all()
permission_classes = [ReBACPermission]
# Single permission for all actions:
rebac_permission = "view"
# Or per-action:
rebac_action_permissions = {
"list": "view",
"retrieve": "view",
"update": "edit",
"destroy": "delete",
}
Queryset Filtering
from django_spicedb.drf import ReBACFilterBackend
class DocumentViewSet(ModelViewSet):
queryset = Document.objects.all()
filter_backends = [ReBACFilterBackend]
rebac_filter_permission = "view" # defaults to "view"
Testing
django-spicedb ships a pytest plugin that spins up an ephemeral SpiceDB container via testcontainers. No Docker Compose, no shared state between runs.
pip install django-spicedb[test]
The plugin registers three session-scoped fixtures automatically (no conftest boilerplate needed):
| Fixture | Scope | Description |
|---|---|---|
spicedb_container |
session | Starts an in-memory SpiceDB (serve-testing) |
spicedb_grpc_endpoint |
session | Returns the host:port string |
spicedb_adapter |
session | A SpiceDBAdapter wired to the container; publishes schema on creation and overrides the global adapter |
Usage
def test_owner_can_view(spicedb_adapter):
"""spicedb_adapter is already connected and the schema is published."""
from django_spicedb.adapters.base import TupleKey, TupleWrite
spicedb_adapter.write_tuples([
TupleWrite(key=TupleKey(
object="document:1",
relation="owner",
subject="user:42",
))
])
assert spicedb_adapter.check("user:42", "view", "document:1")
If testcontainers is not installed the fixtures pytest.skip() automatically,
so your unit tests still run without Docker.
Full Tutorial
Want a complete walkthrough? Read the tutorial - builds a document management system step-by-step, explaining every concept along the way.
Development
See developers.md for development setup, testing, and contributing guidelines.
# Install dependencies
poetry install
# Start SpiceDB
docker compose up -d spicedb
# Run tests
poetry run pytest
Release files for django-spicedb 0.5.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| django_spicedb-0.5.0.tar.gz | 64.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| django_spicedb-0.5.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 151.0 kB
Release files / django_spicedb-0.5.0.tar.gz
| Download URL | django_spicedb-0.5.0.tar.gz |
|---|---|
| Size | 64.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
287cda28c0b30660d178bfe2d914be4c93e2c724e3c92585ce1895427928f76a
|
|
BLAKE2b-256 checksum How to use checksums |
f6e6f3012a7eacca9dd6b0b01d709788f8217056110c4e1b8fe36319ee4fcbb5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
poetry/2.1.1 CPython/3.11.11 Darwin/25.3.0
|
Release files / django_spicedb-0.5.0-py3-none-any.whl
| Download URL | django_spicedb-0.5.0-py3-none-any.whl |
|---|---|
| Size | 86.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
f9af3cd406ef429cb4746f65bdb7bc771f320733f80a1e5b41d3ea37afa5ae3c
|
|
BLAKE2b-256 checksum How to use checksums |
91059cdd1384363c9a4a1f8e97215b42726a4177fd31adcd1c2e8c16b85a92a4
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
poetry/2.1.1 CPython/3.11.11 Darwin/25.3.0
|