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
Metadata
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 | Provenance | Actions |
|---|---|---|---|---|
| django_spicedb-0.5.0.tar.gz | 64.7 kB | Without provenance | Details for django_spicedb-0.5.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| django_spicedb-0.5.0-py3-none-any.whl | Python 3 | none | any | Details for django_spicedb-0.5.0-py3-none-any.whl |
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
|
Security for django-spicedb 0.5.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.