Implement django-spicedb in Django application code. Use for translating authorization rules into RebacModel/RebacMeta definitions, choosing FK/M2M/through bindings, wiring runtime checks and queryset filtering, and handling schema publish and tuple sync safely.
Use this skill when the task is application integration of django-spicedb.
The target is Django app code: models, service layer, and runtime permission calls.
Do not use this skill for modifying internals under django_spicedb/; use $django-spicedb-contributor for that scope.
RebacModel are registered by RebacModelBase.register_type(...).RebacMeta definitions are compiled into a type graph (get_type_graph()).publish_schema(...).can(...), .has_perm(...), and .accessible_by(...).Use through bindings whenever role transitions must change authorization (member vs manager).
Collect these from the task/request:
User, ServiceAccount, Group)Document, Folder, Verification)owner, parent, member, manager)parent->view, workspace->edit)save, M2M add/remove, through writes, bulk_create, QuerySet.update)Translate plain-English policy to type-level rules first.
Example translation:
owner, permission edit = owneredit includes workspace->editparent, permission includes parent->viewKeep permission expressions small and composable.
RebacMetaFor each protected model:
RebacModelobjects = RebacManager()RebacMeta.type_name, relations, and permissionsRebacMeta.through and set through model manager to RebacThroughManager()Use this command:
python manage.py shell -c "import django_spicedb.conf as conf; from django_spicedb.adapters import factory; from django_spicedb.schema import publish_schema; print(publish_schema(factory.get_adapter(), graph=conf.get_type_graph()))"
Use service functions as policy boundaries rather than spreading checks ad hoc in every view.
viewedit or manage.accessible_by(...) for server-side filteringPreferred write paths:
instance.save()add, remove, clear)RebacThroughManagerbulk_create where sync helpers are supportedUse caution with QuerySet.update on authorization-relevant fields; use safer writes when possible.
can/has_perm/accessible_byThis pattern covers:
models.py
from django.contrib.auth import get_user_model
from django.db import models
from django_spicedb.core import register_type
from django_spicedb.integrations.orm import RebacManager, RebacThroughManager
from django_spicedb.models import RebacModel
User = get_user_model()
register_type(User, type_name="user")
class Tenant(models.Model):
slug = models.SlugField(unique=True)
name = models.CharField(max_length=255)
class Workspace(RebacModel):
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name="workspaces")
slug = models.SlugField(max_length=128)
title = models.CharField(max_length=255)
objects = RebacManager()
class Meta:
constraints = [
models.UniqueConstraint(fields=("tenant", "slug"), name="uniq_workspace_per_tenant")
]
class RebacMeta:
type_name = "workspace"
relations = {
"member": {"subject": "user"},
"manager": {"subject": "user"},
}
permissions = {
"view": "member + manager",
"edit": "manager",
}
through = {
"model": "myapp.models.WorkspaceMembership",
"object_fk": "workspace",
"subject_fk": "user",
"role_field": "role",
"roles": {
"member": "member",
"manager": "manager",
},
}
class WorkspaceMembership(models.Model):
ROLE_MEMBER = "member"
ROLE_MANAGER = "manager"
ROLE_CHOICES = [
(ROLE_MEMBER, "Member"),
(ROLE_MANAGER, "Manager"),
]
workspace = models.ForeignKey(
Workspace,
on_delete=models.CASCADE,
related_name="memberships",
)
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="workspace_memberships",
)
role = models.CharField(max_length=16, choices=ROLE_CHOICES, default=ROLE_MEMBER)
objects = RebacThroughManager()
class Meta:
constraints = [
models.UniqueConstraint(fields=("workspace", "user"), name="uniq_workspace_membership")
]
class Folder(RebacModel):
workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE, related_name="folders")
parent = models.ForeignKey(
"self",
null=True,
blank=True,
on_delete=models.CASCADE,
related_name="children",
)
owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name="owned_folders")
name = models.CharField(max_length=255)
objects = RebacManager()
class RebacMeta:
type_name = "folder"
relations = {
"owner": "owner",
"workspace": "workspace",
"parent": "parent",
}
permissions = {
"view": "owner + workspace->view + parent->view",
"edit": "owner + workspace->edit + parent->edit",
}
class Document(RebacModel):
workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE, related_name="documents")
folder = models.ForeignKey(
Folder,
null=True,
blank=True,
on_delete=models.CASCADE,
related_name="documents",
)
owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name="owned_documents")
title = models.CharField(max_length=255)
body = models.TextField(blank=True)
objects = RebacManager()
class RebacMeta:
type_name = "document"
relations = {
"owner": "owner",
"workspace": "workspace",
"parent": "folder",
}
permissions = {
"view": "owner + workspace->view + parent->view",
"edit": "owner + workspace->edit + parent->edit",
}
services.py
from django.core.exceptions import PermissionDenied
from django.db import transaction
from django_spicedb.runtime import can
from django_spicedb.tenant import tenant_context
from .models import Document, Workspace, WorkspaceMembership
def workspace_context(workspace: Workspace) -> dict:
return {
"tenant_id": str(workspace.tenant_id),
"workspace_id": str(workspace.pk),
}
def list_viewable_documents(actor, workspace: Workspace):
ctx = workspace_context(workspace)
return (
Document.objects.filter(workspace=workspace)
.accessible_by(actor, "view", context=ctx)
.select_related("owner", "folder", "workspace")
)
def read_document(actor, workspace: Workspace, document_id: int) -> Document:
document = Document.objects.select_related("workspace", "folder").get(
pk=document_id,
workspace=workspace,
)
with tenant_context(str(workspace.tenant_id)):
allowed = can(actor, "view", document, context=workspace_context(workspace))
if not allowed:
raise PermissionDenied("Missing document:view")
return document
@transaction.atomic
def update_document_title(actor, workspace: Workspace, document_id: int, title: str) -> Document:
document = Document.objects.select_for_update().get(pk=document_id, workspace=workspace)
if not document.has_perm(actor, "edit", context=workspace_context(workspace)):
raise PermissionDenied("Missing document:edit")
document.title = title
document.save(update_fields=["title"])
return document
@transaction.atomic
def invite_workspace_members(actor, workspace: Workspace, user_ids: list[int]) -> None:
if not workspace.has_perm(actor, "edit", context=workspace_context(workspace)):
raise PermissionDenied("Missing workspace:edit")
rows = [
WorkspaceMembership(
workspace=workspace,
user_id=user_id,
role=WorkspaceMembership.ROLE_MEMBER,
)
for user_id in user_ids
]
WorkspaceMembership.objects.bulk_create(rows, ignore_conflicts=True)
This pattern covers:
managemodels.py
from django.contrib.auth import get_user_model
from django.db import models
from django_spicedb.core import register_type
from django_spicedb.integrations.orm import RebacManager, RebacThroughManager
from django_spicedb.models import RebacModel
User = get_user_model()
register_type(User, type_name="user")
class Group(RebacModel):
slug = models.SlugField(unique=True)
title = models.CharField(max_length=255)
objects = RebacManager()
class RebacMeta:
type_name = "group"
relations = {
"member": {"subject": "user"},
"manager": {"subject": "user"},
}
permissions = {
"view": "member + manager",
"manage": "manager",
}
through = {
"model": "myapp.models.GroupMembership",
"object_fk": "group",
"subject_fk": "user",
"role_field": "role",
"roles": {
"member": "member",
"manager": "manager",
},
}
class GroupMembership(models.Model):
ROLE_MEMBER = "member"
ROLE_MANAGER = "manager"
ROLE_CHOICES = [
(ROLE_MEMBER, "Member"),
(ROLE_MANAGER, "Manager"),
]
group = models.ForeignKey(Group, on_delete=models.CASCADE, related_name="memberships")
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="group_memberships")
role = models.CharField(max_length=16, choices=ROLE_CHOICES, default=ROLE_MEMBER)
objects = RebacThroughManager()
class Meta:
constraints = [
models.UniqueConstraint(fields=("group", "user"), name="uniq_group_user_membership")
]
class Verification(RebacModel):
STATUS_DRAFT = "draft"
STATUS_PENDING = "pending"
STATUS_APPROVED = "approved"
STATUS_REJECTED = "rejected"
STATUS_CHOICES = [
(STATUS_DRAFT, "Draft"),
(STATUS_PENDING, "Pending"),
(STATUS_APPROVED, "Approved"),
(STATUS_REJECTED, "Rejected"),
]
group = models.ForeignKey(Group, on_delete=models.CASCADE, related_name="verifications")
owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name="owned_verifications")
title = models.CharField(max_length=255)
payload = models.JSONField(default=dict, blank=True)
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_DRAFT)
objects = RebacManager()
class RebacMeta:
type_name = "verification"
relations = {
"owner": "owner",
"parent": "group",
}
permissions = {
"view": "owner + parent->view",
"manage": "owner + parent->manage",
}
services.py
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import transaction
from .models import Group, GroupMembership, Verification
def list_visible_verifications(actor, group: Group):
return (
Verification.objects.filter(group=group)
.accessible_by(actor, "view")
.select_related("group", "owner")
)
@transaction.atomic
def submit_verification(actor, group: Group, title: str, payload: dict) -> Verification:
if not group.has_perm(actor, "view"):
raise PermissionDenied("Missing group:view")
return Verification.objects.create(
group=group,
owner=actor,
title=title,
payload=payload,
status=Verification.STATUS_PENDING,
)
def require_manage_access(actor, verification_id: int) -> Verification:
verification = Verification.objects.select_related("group", "owner").get(pk=verification_id)
if not verification.has_perm(actor, "manage"):
raise PermissionDenied("Missing verification:manage")
return verification
@transaction.atomic
def approve_verification(actor, verification_id: int) -> Verification:
verification = require_manage_access(actor, verification_id)
if verification.status != Verification.STATUS_PENDING:
raise ValidationError("Only pending verifications can be approved")
verification.status = Verification.STATUS_APPROVED
verification.save(update_fields=["status"])
return verification
@transaction.atomic
def reject_verification(actor, verification_id: int) -> Verification:
verification = require_manage_access(actor, verification_id)
if verification.status != Verification.STATUS_PENDING:
raise ValidationError("Only pending verifications can be rejected")
verification.status = Verification.STATUS_REJECTED
verification.save(update_fields=["status"])
return verification
@transaction.atomic
def set_group_role(actor, membership_id: int, new_role: str) -> GroupMembership:
membership = GroupMembership.objects.select_related("group").get(pk=membership_id)
if not membership.group.has_perm(actor, "manage"):
raise PermissionDenied("Missing group:manage")
membership.role = new_role
membership.save(update_fields=["role"])
return membership
@transaction.atomic
def bulk_add_group_members(actor, group: Group, user_ids: list[int]) -> None:
if not group.has_perm(actor, "manage"):
raise PermissionDenied("Missing group:manage")
rows = [
GroupMembership(group=group, user_id=user_id, role=GroupMembership.ROLE_MEMBER)
for user_id in user_ids
]
GroupMembership.objects.bulk_create(rows, ignore_conflicts=True)
references/how-it-works.md for internals from an integrator perspectivereferences/app-integration.md for additional implementation patternsreferences/debugging-playbook.md for incident triagereferences/test-playbook.md for optional test strategyreferences/claude-codex-prompt-pack.md for reusable prompts