AlexanderCollins/TurboDRF

The dead simple Django REST Framework API generator with role-based permissions

TurboDRF

PyPI Version Tests Coverage Python Django License

Dead simple Django REST Framework API generator with role-based permissions.

Turn your Django models into fully-featured REST APIs with a mixin and a method. Zero boilerplate.

Install

pip install turbodrf

# Optional: faster JSON rendering with msgspec
pip install "turbodrf[fast]"

Quick start

1. Settings. Add the apps, and roles that grant each action:

INSTALLED_APPS = [
    # ... Django's default apps ...
    'rest_framework',
    'drf_yasg',      # serves the Swagger / ReDoc pages
    'turbodrf',
    'shop',
]

TURBODRF_ROLES = {
    'editor': [
        'shop.author.read', 'shop.author.create', 'shop.author.update', 'shop.author.delete',
        'shop.book.read', 'shop.book.create', 'shop.book.update', 'shop.book.delete',
    ],
    'viewer': ['shop.author.read', 'shop.book.read'],
}

2. Models. Add the mixin and a turbodrf() classmethod:

from django.db import models
from turbodrf.mixins import TurboDRFMixin

class Author(models.Model, TurboDRFMixin):
    name = models.CharField(max_length=100)
    email = models.EmailField()

    @classmethod
    def turbodrf(cls):
        return {'fields': ['id', 'name', 'email']}

class Book(models.Model, TurboDRFMixin):
    title = models.CharField(max_length=200)
    author = models.ForeignKey(Author, on_delete=models.CASCADE)
    price = models.DecimalField(max_digits=10, decimal_places=2)

    @classmethod
    def turbodrf(cls):
        return {
            'fields': {
                'list': ['id', 'title', 'author', 'author__name', 'price'],
                'detail': ['id', 'title', 'author', 'author__name', 'author__email', 'price'],
            },
            'searchable_fields': ['title'],
        }

3. Give users roles. TurboDRF reads user.roles, a list of role names. For example, use the names of the user's Django groups, in your app's AppConfig.ready():

from django.contrib.auth.models import User

User.add_to_class('roles', property(lambda user: [g.name for g in user.groups.all()]))

Other options: Assigning roles to users.

4. URLs.

from django.urls import include, path

urlpatterns = [
    path('api/', include('turbodrf.urls')),
]

Run makemigrations and migrate, put a user in the editor group, and the API is up:

GET    /api/books/                          # List
GET    /api/books/1/                        # Detail
POST   /api/books/                          # Create
PUT    /api/books/1/                        # Update
DELETE /api/books/1/                        # Delete
GET    /api/books/?search=django            # Search
GET    /api/books/?price__lt=20             # Filter
GET    /api/books/?fields=title,price       # Select fields

A user with no role gets a 403 on every endpoint, and a nested field such as author__name needs read on the related model (shop.author.read). Roles don't scope rows: every editor sees every book until the model declares an owner_field or tenant_field (see Tenancy).

The quick start guide runs these steps in a new project, with users and requests.

Documentation

Full docs: https://turbodrf.com/docs/

  • Installation -- requirements, extras, checking the configuration
  • Configuration -- all turbodrf() options: field selection, nested & computed fields, custom endpoints (actions), read-only endpoints, method allow-lists, full_clean validation
  • Permissions -- role-based, field-level, and Django default permissions
  • Tenancy & row-level access -- predicates, multi-tenancy, FK injection defense
  • Platform scope -- an API for your own staff across tenants: opt-in per model, a gate on every route, separate roles
  • Performance -- queries per request, indexes, fast JSON rendering
  • Filtering & Search -- filtering, search, ordering, OR queries
  • Integrations -- allauth, Keycloak, drf-api-tracking, Sentry (all experimental)
  • Security -- sensitive fields, secure defaults, error responses
  • Management Commands -- turbodrf_check
  • Settings Reference -- every TURBODRF_* setting in one place

Permissions and access control

TurboDRF answers four standard authorization questions, in three layers that all apply to every request (AND'd together):

Question Layer Mechanism
Can this user reach this endpoint? RBAC (Role-Based Access Control) Roles in TURBODRF_ROLES map to permissions. permissions.py checks <app>.<model>.<action> for the request method.
Which rows can this user see? Row-level access Predicates declared in turbodrf() config. Mandatory tenant boundary + discretionary within-tenant predicates (Owner, Members, Either, Custom).
Which fields can this user read or write? Field-level permissions Per-field rules <app>.<model>.<field>.read / .write in TURBODRF_ROLES. Hidden fields are stripped from responses, search, ordering, filters, and OPTIONS metadata.
Are FK targets the user provides actually theirs? Write validation On every create/update, FK and M2M values in the request body are validated against the related model's tenant boundary and predicate stack, on every model (shared ones included). Cross-tenant or invisible targets return 400.

Field rules bind anonymous callers too: public_access: True gives them model-level read, and a field that any role has a read rule for needs a grant from a guest role. See Permissions.

How it actually works (concrete walk-through)

A multi-tenant SaaS has two workspaces (ABC and XYZ) and three roles: member, manager, admin. A Project model is configured:

class Project(models.Model, TurboDRFMixin):
    title = models.CharField(max_length=200)
    workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE)
    owner = models.ForeignKey(User, on_delete=models.CASCADE)

    @classmethod
    def turbodrf(cls):
        return {
            'tenant_field': 'workspace',                       # mandatory wall
            'owner_field': 'owner',             # within-tenant rule
            'bypass_owner_roles': ['manager', 'admin'],        # roles ignore owner check
            'fields': ['title', 'workspace', 'owner'],
        }

Plus two project-wide settings:

TURBODRF_TENANT_MODEL = 'accounts.Workspace'
TURBODRF_TENANT_USER_FIELD = 'workspace'   # request.user.workspace → tenant

Now a request GET /api/projects/ from Alice (member at ABC) goes through:

  1. Permission gate — Alice's role member has app.project.read. Pass.
  2. Tenant filter (mandatory, applied first, never bypassable): sql WHERE project.workspace_id = <Alice's workspace>
  3. Owner filter (Alice has no bypass role, so this layer applies): sql AND project.owner_id = <Alice's user id>
  4. Field stripping — Alice's role has read on title, workspace, owner but maybe not all configured fields. Hidden ones are removed from the response.

If Alice tries cross-tenant tricks: - GET /api/projects/<XYZ_project_id>/ → 404 (not 403, no existence leak) - PATCH /api/projects/<her_project_id>/ {"workspace": <XYZ>} → 400 (tenant reassignment rejected) - POST /api/tasks/ {"project": <XYZ_project_id>} → 400 (FK injection: she can't reference another tenant's project)

If a manager (with bypass) at ABC asks for /api/projects/:

WHERE project.workspace_id = <ABC's workspace id>
-- no owner filter (manager bypassed it)

Manager sees all ABC projects, but still can't see XYZ — the tenant boundary is mandatory and applied separately from the predicate algebra (it's a setting, not a predicate). This rules out an entire class of compositional bugs where bypass roles could OR-compose their way past the tenant wall.

Quick recipes

# Multi-tenant SaaS — most common case
{'tenant_field': 'store', 'owner_field': 'customer', 'bypass_owner_roles': ['staff']}

# Personal data app (no TURBODRF_TENANT_MODEL; with one, owner_field alone fails the startup check)
{'owner_field': 'author', 'bypass_owner_roles': ['admin']}

# Reference data (currencies, country codes — not tenant-scoped)
{'tenancy': 'shared'}

# M2M membership (Slack channels, Linear projects)
{'tenant_field': 'workspace', 'visibility': [Members('participants')]}

# Several owner columns (a match on any of them shows the row)
{'tenant_field': 'workspace', 'owner_field': ['author', 'reviewer']}

# Power-form composition: shared_with users can edit the row, not reassign its owner
{'tenant_field': 'workspace', 'visibility': [Either(Owner('owner'), Members('shared_with'))]}

See Tenancy for the full predicate vocabulary, hard-fail-at-startup behavior, and 404-vs-403 semantics.

Staff across tenants

An operator's own staff console reads and acts on every tenant's rows. Give a model a 'platform' block and mount a second router with a gate:

# Job.turbodrf(): no tenant endpoint, a read-only console endpoint
{'enabled': False, 'tenant_field': 'workspace',
 'platform': {'endpoint': 'jobs', 'read_only': True,
              'fields': ['id', 'kind', 'status', 'workspace__name'],
              'actions': [retry]}}

# console/urls.py
console = TurboDRFRouter(scope='platform', gate=[IsPlatformStaff])

Platform roles (TURBODRF_PLATFORM_ROLES) are separate from tenant roles. See Platform scope.

License

MIT License. See LICENSE for details.

Stars
39
100.00% more than last month
Forks
0
Open Issues
0