Skip to content

Permission-Scoped Schema: End-to-End Guide

This is the narrative guide to django-graphex's v2 permission stack. It walks a single Blog API through every layer — what each user sees, what each user may do, and how the pieces reinforce each other — with real, runnable code and the exact responses the server returns.

The reference pages remain Permissions (permission classes), Security (middlewares and the security model), Views (the HTTP endpoints) and Subscriptions (transports). This page ties them together.

The three layers at a glance

Layer What it gates Where it acts Configured by
DjangoModelPermissions May this action run? Each CRUD/subscribe action checks the caller's Django model permissions at runtime, right before the resolver. Per DjangoModelType (permission_classes) permission_classes on the type
API_ACCESS_GROUP May this caller use the endpoint at all? Members of one Django auth Group pass; everyone else gets a generic 403 before any GraphQL parsing. The whole AuthenticatedGraphQLView endpoint API_ACCESS_GROUP setting
PERMISSION_SCOPED_SCHEMA What schema does this caller even see? Each request validates and executes against a schema pruned to the caller's permissions — fields they lack perms for do not exist for them. Per request on AuthenticatedGraphQLView; per connection on the subscription transports PERMISSION_SCOPED_SCHEMA setting

The third layer is where hide, block and reject unify under one flag:

  • Hide — introspection reflects only the caller's pruned schema, so hidden fields never show up in tooling or autocomplete.
  • Block — selecting a pruned field fails validation with a native Cannot query field "…" — a not-found indistinguishable from a typo, never an authorization error that would confirm the field exists.
  • Reject — a caller whose entire Query root prunes away gets the endpoint's generic 403, byte-identical to every other endpoint denial.

Layers 1 and 3 are two halves of the same model: the schema layer makes a forbidden operation invisible, the runtime layer makes it fail even if a request somehow reaches the full schema (the public GraphQLView, the flag turned off, a custom transport). Use both.

Worked example: a Blog API

Two models, one schema, three users.

from django.db import models


class Post(models.Model):
    title = models.CharField(max_length=200)
    body = models.TextField()


class Comment(models.Model):
    post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name="comments")
    text = models.TextField()
from django_graphex.core import BooleanField, ObjectType
from django_graphex.permissions import DjangoModelPermissions
from django_graphex.schema import DjangoGraphQLSchema
from django_graphex.types import DjangoModelType

from blog.models import Comment, Post


class PostType(DjangoModelType):
    permission_classes = [DjangoModelPermissions]   # layer 1: runtime

    class Meta:
        model = Post
        stream = "posts"        # enables the subscription (optional)


class CommentType(DjangoModelType):
    permission_classes = [DjangoModelPermissions]

    class Meta:
        model = Comment


class Query(ObjectType):
    post, all_posts = PostType.QueryFields()
    comment, all_comments = CommentType.QueryFields()


class Mutation(ObjectType):
    create_post = PostType.CreateField()
    update_post = PostType.UpdateField()
    delete_post = PostType.DeleteField()
    # Untagged custom field -> PUBLIC: it survives every pruned variant.
    newsletter_signup = BooleanField(description="Public, untagged")

    def resolve_newsletter_signup(root, info):
        return True


class Subscription(ObjectType):
    post_subscription = PostType.SubscriptionField()


schema = DjangoGraphQLSchema(query=Query, mutation=Mutation, subscription=Subscription)
DJANGO_GRAPHEX = {
    "SCHEMA": "blog.schema.schema",
    "PERMISSION_SCOPED_SCHEMA": True,   # layer 3: per-role pruned schema
}
from django.urls import path

from django_graphex.views import AuthenticatedGraphQLView

urlpatterns = [
    path("graphql", AuthenticatedGraphQLView.as_view(graphiql=True)),
]

Subscriptions are optional here

Meta.stream / SubscriptionField() require the [subscriptions] extra (uv add "django-graphex[subscriptions]"). Drop the Subscription root and stream if you only want queries and mutations — everything else on this page works the same.

DjangoGraphQLSchema labels every generated CRUD field with the permissions it requires (the composite table below) — that labeling is what the pruner consumes, and it is why PERMISSION_SCOPED_SCHEMA requires a DjangoGraphQLSchema.

Now the three users:

from django.contrib.auth.models import Permission, User

ana = User.objects.create_user("ana", password="…")
ana.user_permissions.add(
    Permission.objects.get(codename="view_post", content_type__app_label="blog"),
)

luis = User.objects.create_user("luis", password="…")
luis.user_permissions.add(
    *Permission.objects.filter(
        codename__in=["view_post", "change_post"], content_type__app_label="blog"
    ),
)

admin = User.objects.create_superuser("admin", "admin@example.com", "…")

What each role sees

With PERMISSION_SCOPED_SCHEMA: True, each caller's introspection — GraphiQL, codegen, __schema queries — reflects their schema. Side by side (descriptions elided; the object types themselves are unchanged):

type Query {
  post(id: ID!): PostGenericType
  allPosts: PostListType
}

type Mutation {
  newsletterSignup: Boolean
}

# comment / allComments, every Post mutation, the whole Subscription
# root and every type reachable only through them are ABSENT.
type Query {
  post(id: ID!): PostGenericType
  allPosts: PostListType
}

type Mutation {
  updatePost(newPost: PostUpdateGenericType!): PostType
  newsletterSignup: Boolean
}

type Subscription {
  postSubscription(action: PostSubscriptionAction!, id: ID, filters: String): PostSubscriptionEvent
}

enum PostSubscriptionAction {
  UPDATE
}

# createPost / deletePost are absent; the subscription action enum
# keeps ONLY the UPDATE value (see the subscriptions section below).
type Query {
  post(id: ID!): PostGenericType
  allPosts: PostListType
  comment(id: ID!): CommentGenericType
  allComments: CommentListType
}

type Mutation {
  createPost(newPost: PostCreateGenericType!): PostType
  updatePost(newPost: PostUpdateGenericType!): PostType
  deletePost(id: ID!): PostType
  newsletterSignup: Boolean
}

type Subscription {
  postSubscription(action: PostSubscriptionAction!, id: ID, filters: String): PostSubscriptionEvent
}

enum PostSubscriptionAction {
  CREATE
  UPDATE
  DELETE
  ALL_ACTIONS
}

# An ACTIVE superuser always receives the FULL schema — no permission
# signature is even computed for them.

You can watch the pruning live: ana introspecting the Mutation type sees only the public field, luis sees his one mutation too.

{ __type(name: "Mutation") { fields { name } } }
{ "data": { "__type": { "fields": [{ "name": "newsletterSignup" }] } } }
{ "data": { "__type": { "fields": [{ "name": "updatePost" }, { "name": "newsletterSignup" }] } } }

Denials never leak

ana tries updatePost. The field is not in her schema, so validation fails with a native not-found — HTTP 400, no authorization vocabulary, nothing confirming the field exists for anyone else:

mutation { updatePost(newPost: { id: 1, title: "Edited" }) { ok } }
{
  "errors": [
    {
      "message": "Cannot query field 'updatePost' on type 'Mutation'.",
      "locations": [{ "line": 1, "column": 12 }]
    }
  ]
}

Even the did-you-mean suggestions are computed against her pruned schema — ana querying allComments gets "Cannot query field 'allComments' on type 'Query'. Did you mean 'allPosts'?" — only fields she can see are ever suggested.

A user with zero relevant permissions. Every Query field requires a perm they lack, so their pruned Query root is empty — the request is refused before validation or execution with the endpoint's generic 403, byte-identical to a permission_classes or API_ACCESS_GROUP denial:

{
  "errors": [{ "message": "You do not have permission to access this endpoint." }]
}

luis runs updatePost. The field is in his schema, the runtime DjangoModelPermissions check passes (change_post + view_post), the mutation executes — HTTP 200:

{
  "data": {
    "updatePost": { "ok": true, "post": { "id": "1", "title": "Edited" } }
  }
}

admin sees and can do everything. An active superuser gets the full schema (no signature computed) and Django's ModelBackend grants every permission, so the runtime layer passes too.

Why does ana still have a Mutation root?

The untagged newsletterSignup field is public, so it survives every pruned variant and keeps the Mutation root alive — which is exactly why ana's updatePost reads as Cannot query field on Mutation. Untagged fields are the "public surface" of a permission-scoped schema; a root with no public fields simply disappears for callers with no relevant perms (and an empty Query root triggers the generic 403 above).

Layer 1: runtime enforcement with DjangoModelPermissions

permission_classes = [DjangoModelPermissions] on a DjangoModelType checks the caller's Django model permissions at runtime, per action, with user.has_perms — see Permissions for the full reference.

The composite permission table

Both the runtime check and the schema labels the pruner consumes come from the same normative table. It is composite: because a mutation or subscription payload returns instance data, every write verb also requires view:

Action Required permission(s) (M = {app_label}.{verb}_{model_name})
retrieve / list view_M
create add_M + view_M
update change_M + view_M
delete delete_M + view_M
subscribe CREATE view_M + add_M
subscribe UPDATE view_M + change_M
subscribe DELETE view_M + delete_M
subscribe ALL_ACTIONS view_M + add_M + change_M + delete_M

That is why luis (view_post + change_post) gets the Post read fields, updatePost and the UPDATE subscribe action — and nothing else.

The write-only escape hatch

Because the default is composite, a write-only flow (an inbox where users may submit records they can never read back) overrides perms_map for that action with just the write verb:

from django_graphex.permissions import DjangoModelPermissions


class WriteOnlyInbox(DjangoModelPermissions):
    perms_map = {
        **DjangoModelPermissions.perms_map,
        # create requires only `add` — the `view` requirement is dropped.
        "create": ("{app_label}.add_{model_name}",),
    }

Keep both layers in sync

perms_map customizes the runtime check only. The schema labels (what the pruner hides) come from the composite table unless you override them explicitly with required_perms — for a write-only inbox that mounts only the create field, pair the perms_map subclass with required_perms = ["myapp.add_message"] on that mutation class so the field stays visible to write-only users. (required_perms applies to every operation field the class generates, so keep write-only mutation classes single-purpose.)

Labeling custom fields and mutations: required_perms

Generated CRUD fields are labeled automatically. Hand-declared fields are untagged by default — untagged means public: they survive every pruned variant and are never runtime-checked by this stack. To opt a custom field into pruning, pass required_perms to the low-level field() descriptor:

from graphql import GraphQLString

from django_graphex.core import ObjectType, field


class Query(ObjectType):
    # Visible only to callers holding blog.view_post:
    editorial_notes = field(GraphQLString, required_perms=["blog.view_post"])
    # Untagged -> public, in everyone's schema:
    server_time = field(GraphQLString)

On a DjangoModelMutation (or the mutation fields a DjangoModelType generates), the required_perms class attribute replaces the composite default for every operation field the class generates:

from django_graphex.mutation import DjangoModelMutation

from blog.models import Post


class PublishPostMutation(DjangoModelMutation):
    # Replaces the composite default (add/change/delete + view) on the
    # generated createPost/updatePost/deletePost fields.
    required_perms = ["blog.publish_post"]

    class Meta:
        model = Post

The schema's global label-set (the union of every stamped permission) is what a caller's permissions are projected onto — permissions outside it can never affect pruning, so unrelated grants don't fragment the schema cache.

Layer 2: the endpoint gate — API_ACCESS_GROUP

Independently of what a caller could see or do inside the schema, API_ACCESS_GROUP locks the whole AuthenticatedGraphQLView endpoint to members of one Django auth Group:

DJANGO_GRAPHEX = {
    "SCHEMA": "blog.schema.schema",
    "PERMISSION_SCOPED_SCHEMA": True,
    "API_ACCESS_GROUP": "api-users",   # "" (default) = gate off
}
from django.contrib.auth.models import Group

api_users = Group.objects.create(name="api-users")
api_users.user_set.add(ana, luis)      # members may use the endpoint

A member gets a normal response:

curl -s https://api.example.com/graphql \
  -H "Content-Type: application/json" \
  -H "Cookie: sessionid=<ana-session>" \
  -d '{"query": "{ allPosts { totalCount } }"}'
# HTTP 200
# {"data":{"allPosts":{"totalCount":1}}}

A non-member — authenticated or not — is rejected before any GraphQL parsing, with the same generic message as every other endpoint denial (the group requirement is never leaked):

curl -s https://api.example.com/graphql \
  -H "Content-Type: application/json" \
  -H "Cookie: sessionid=<non-member-session>" \
  -d '{"query": "{ allPosts { totalCount } }"}'
# HTTP 403
# {"errors":[{"message":"You do not have permission to access this endpoint."}]}

An active superuser always bypasses the gate (hardcoded invariant):

curl -s https://api.example.com/graphql \
  -H "Content-Type: application/json" \
  -H "Cookie: sessionid=<admin-session>" \
  -d '{"query": "{ allPosts { totalCount } }"}'
# HTTP 200 — superusers pass regardless of group membership

The gate is fail-closed (a missing/anonymous user is denied), runs on top of permission_classes (it survives as_view(permission_classes=…) overrides), and does not affect the public GraphQLView. Full semantics: Views → Restricting the endpoint to a group.

Subscriptions: per-action pruning

A subscription field is labeled per action (the composite table above), and pruning acts on the action enum values, not just the field:

  • A caller holding view + change keeps the subscription field, but its action enum keeps only UPDATE — luis's schema above shows exactly that.
  • ALL_ACTIONS survives only when every write verb is held.
  • A caller with no permitted action (e.g. ana: view_post but no write verb) loses the entire subscription field — and if nothing else is left, the whole Subscription root.

So when luis tries to subscribe to an action he may not observe, the operation is rejected at validation — the enum value does not exist in his schema:

subscription { postSubscription(action: DELETE) { id } }
{
  "errors": [
    { "message": "Value 'DELETE' does not exist in 'PostSubscriptionAction' enum." }
  ]
}

Wiring the transports

Subscriptions are served by the SSE/WS transports, not the HTTP view, so the pruned schema reaches them through their schema_provider — a callable provider(user) -> GraphQLSchema resolved per connection. Wire it to the bundled pruned_schema_for helper, which honors the same PERMISSION_SCOPED_SCHEMA flag (read per connection; an active superuser gets the full schema; with the flag off it returns the full schema unchanged):

# project/asgi.py
import os

from django.core.asgi import get_asgi_application

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings")
django_asgi_app = get_asgi_application()

from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from django.urls import path

from django_graphex.core.permission_signature_cache import pruned_schema_for
from django_graphex.subscriptions.transports.ws import subscription_ws_consumer

from blog.schema import schema

_full = schema.graphql_schema

application = ProtocolTypeRouter(
    {
        "http": django_asgi_app,
        # The provider is resolved once per socket with scope["user"].
        "websocket": AuthMiddlewareStack(
            URLRouter(
                [
                    path(
                        "ws/graphql/",
                        subscription_ws_consumer(
                            schema_provider=lambda user: pruned_schema_for(user, _full),
                        ).as_asgi(),
                    ),
                ]
            )
        ),
    }
)
# urls.py
from django.urls import path

from django_graphex.core.permission_signature_cache import pruned_schema_for
from django_graphex.subscriptions.transports.sse import subscription_sse_view

from blog.schema import schema

_full = schema.graphql_schema

urlpatterns = [
    # The provider is resolved per request/stream with request.user.
    path(
        "graphql/stream",
        subscription_sse_view(
            schema_provider=lambda user: pruned_schema_for(user, _full),
        ),
    ),
]

Prerequisites

  • A labeled schema — the pruner needs the DjangoGraphQLSchema labels, same as the HTTP endpoint.
  • A real user on the connection — SSE reads request.user; WebSocket reads scope["user"], so the WS routing MUST be wrapped in Channels' AuthMiddlewareStack (or an equivalent that populates scope["user"]). Otherwise the provider sees AnonymousUser.

Per-socket resolution and staleness

For WebSocket, the provider is resolved once per socket (on the first subscribe) and cached for the life of the connection — every multiplexed operation on that socket shares one schema, and a permission change (or a flag toggle) only takes effect on the next connection. For SSE (one request = one stream), each new stream picks up the current state.

And the runtime half still stands: DjangoModelPermissions checks the requested subscribe action at runtime with the same composite rule, so even a request that reaches the full schema (custom provider, flag off) is denied a subscribe for an action whose write verb the caller lacks. See Subscriptions → Authorization and row-scoping.

Operations: the pruned-schema cache

How pruned schemas are built, cached and invalidated

  • One schema per permission-set, not per user. Each caller's live permissions are projected onto the schema's label-set and hashed into a permission signature (SHA-256 of the sorted relevant perms). Callers who differ only in irrelevant perms share one signature — and one pruned schema.
  • Lazy, bounded LRU. A pruned variant is built on the first request for its signature (concurrent first-requests build it exactly once) and cached in-process in an LRU bounded by PERMISSION_SCHEMA_CACHE_MAXSIZE (default 64, sized so a realistic role-driven working set of ~50 signatures fits; a full cache stays well under 1 MiB).
  • Revocation applies on the next request. The signature is recomputed from user.get_all_permissions() on every request — never keyed by user id, never stored in an external cache — so a grant or revoke changes the signature and the caller gets the matching schema on their next request (next connection for WebSocket). No stale schema is possible.
  • A restart resets the cache. It is in-process only; pruned variants are rebuilt lazily after every deploy/restart.
  • The response cache is signature-aware. With CACHE_ACTIVE on, the caller's permission signature is folded into the response-cache key, so two callers sharing a cache identity but holding different relevant perms can never read each other's cached bodies. (Superusers and flag-off requests contribute an empty segment — keys stay byte-identical to the unpruned setup.)

See also