Permissions and Scopes in Django Control Room
Every panel gets a permission system for free. Here's how it actually works, and how to use it to lock down specific views without writing your own auth checks.
A Control Room panel is rarely all-or-nothing. A cache panel might let anyone on staff browse keys, but only a smaller group should be allowed to flush the whole cache. A URL panel might let anyone browse the route table, but sending a live test request against those routes is a different level of risk entirely.
dj-control-room-base ships a permission system that handles exactly this, built directly into PanelConfig. You get a sensible baseline for free (staff only), a panel-wide policy you can tighten in one place, and scopes: named checkpoints that let you set a stricter rule for one specific view without touching any other view in the panel.
This guide walks through the whole model, from the baseline check up to overriding a single scope from project settings, using real code from the official panels.
What you'll learn
- What the baseline permission check does before any of your settings are even read
ALLOWED_GROUPSandREQUIRE_SUPERUSER, the two panel-wide controls- What a scope actually is, and how
SCOPE_PERMISSIONSoverrides it per view - A real multi-scope panel (
dj-urls-panel), and how to lock down its riskiest view - How to check permissions outside of a decorator, including inside templates and panel tools (MCP)
The baseline: authenticated and staff
Before any setting you configure is even consulted, every view wrapped in @panel_config.permission_required(...) runs the same baseline check:
from .conf import panel_config
@panel_config.permission_required("index")
def index(request):
...With zero configuration, that one decorator already means:
- Anonymous visitors are redirected to the Django admin login page
- Authenticated users who aren't staff get a 403 (
PermissionDenied) - Any staff user is allowed through
This mirrors how Django's own admin behaves, and it's the floor every other rule in this guide builds on top of. You never need to write @staff_member_required yourself; it's already in there.
Panel-wide permissions
Above the staff baseline, every panel gets two settings that apply to all of its views at once. Both live under the panel's own settings key, for example DJ_MY_PANEL_SETTINGS.
ALLOWED_GROUPS
A list of Django group names allowed to access the panel. Empty (the default) means any staff member can get in. Non-empty means the user must belong to at least one of the named groups.
DJ_MY_PANEL_SETTINGS = {
"ALLOWED_GROUPS": ["ops", "support"],
}REQUIRE_SUPERUSER
When True, only superusers can reach the panel at all. Regular staff get a 403, even if they're in an allowed group.
DJ_MY_PANEL_SETTINGS = {
"REQUIRE_SUPERUSER": True,
}These two keys are entirely optional. If you don't set them, the panel falls back to PANEL_BUILTIN_DEFAULTS: an empty ALLOWED_GROUPS and REQUIRE_SUPERUSER set to False, meaning "any staff member." Panel authors never have to declare these in their own conf.py; they're inherited automatically.
Superusers always win
A superuser bypasses ALLOWED_GROUPS entirely and is exempt from REQUIRE_SUPERUSER by definition, as long as they're staff. This matches how Django's own admin treats superusers, so there's no separate "superuser override" setting to configure.
Introducing scopes
ALLOWED_GROUPS and REQUIRE_SUPERUSER are blunt instruments: they apply to the whole panel. Most panels have more than one view, and not every view carries the same amount of risk. That's what scopes are for.
A scope is just a string you pass to permission_required(). It doesn't do anything on its own, it's simply a label that lets a project owner target one specific view later, from settings, without editing the panel's code:
from .conf import panel_config
@panel_config.permission_required("dashboard")
def dashboard(request):
...
@panel_config.permission_required("flush_cache")
def flush_cache(request):
...By default, every scope inherits the exact same panel-wide rule from Step 2. Nothing changes in behavior until a project owner adds an entry for that scope under SCOPE_PERMISSIONS. Think of scopes as hooks you leave in place, ready to be tightened later, rather than restrictions you're applying yourself.
A real example: dj-urls-panel's three scopes
dj-urls-panel browses your project's URL configuration, and it also ships a "testing interface" that fires a real HTTP request at one of those routes from inside the admin. Those two capabilities are not equally risky, so the panel splits them into three scopes:
@panel_config.permission_required("index")
def index(request):
"""Browse and search every registered URL pattern."""
...
@panel_config.permission_required("detail")
def url_detail(request, pattern):
"""Show details for a single URL: view, methods, parameters."""
...
@method_decorator(panel_config.permission_required("execute"), name="dispatch")
class ExecuteRequestView(View):
"""Send a live HTTP request against a route, on the user's behalf."""
...index and detail are read-only: browsing routes and inspecting one of them. execute is fundamentally different: it can hit real endpoints, potentially with the current user's session, and even mutate data if the route allows it. Giving it its own scope means a project owner can allow browsing for every staff member while restricting the "send a real request" button to a much smaller group, all without the panel author having to guess who that group should be.
Overriding a scope from your project
This is where SCOPE_PERMISSIONS comes in. It's a dict keyed by scope name, and each entry accepts the exact same ALLOWED_GROUPS / REQUIRE_SUPERUSER keys as the panel-wide settings, just scoped to one view instead of the whole panel.
Here's how a project might leave URL browsing open to every staff member, while locking the testing interface down to a dedicated group:
# settings.py
DJ_URLS_PANEL_SETTINGS = {
# Panel-wide default: any staff member can browse and inspect URLs
"ALLOWED_GROUPS": [],
"REQUIRE_SUPERUSER": False,
"SCOPE_PERMISSIONS": {
# Only "platform-admins" may fire live test requests
"execute": {
"ALLOWED_GROUPS": ["platform-admins"],
},
},
}Notice that index and detail aren't mentioned at all. Any scope missing from SCOPE_PERMISSIONS simply falls back to the panel-wide rule, so you only ever have to write down the exceptions.
You can also require superuser access for one scope while leaving the rest of the panel open to groups:
DJ_MY_PANEL_SETTINGS = {
"ALLOWED_GROUPS": ["ops"],
"SCOPE_PERMISSIONS": {
"design-system": {"REQUIRE_SUPERUSER": True},
"examples": {"ALLOWED_GROUPS": ["editors"]},
},
}Put together, every request is resolved in this order:
- Not authenticated: redirect to the admin login page
- Not staff: 403
- Superuser: always allowed
REQUIRE_SUPERUSERisTruefor the resolved scope: 403 for non-superusersALLOWED_GROUPSis non-empty for the resolved scope: 403 unless the user is in one of those groups- Otherwise: allowed
"The resolved scope" means the panel-wide settings merged with that scope's entry in SCOPE_PERMISSIONS, if one exists. Settings merge in a fixed order everywhere in dj-control-room-base: built-in defaults, then the panel author's own defaults in conf.py, then hub-level overrides (if dj-control-room is installed), then your project's own settings. Your project's settings always win.
Checking permissions outside a decorator
A decorator is enough to protect a view, but it won't stop you from rendering a link or a button to that view somewhere a user shouldn't see it. For that, call panel_config.has_permission(request, scope) directly and pass the result into your template context:
@panel_config.permission_required("detail")
def url_detail(request, pattern):
context = panel_config.get_context(
request,
title="URL Detail",
can_execute=panel_config.has_permission(request, "execute"),
)
return render(request, "admin/dj_urls_panel/detail.html", context)can_execute isn't a special keyword or something get_context() recognizes; it's just a plain template variable we made up. get_context(request, **extra) passes through any keyword argument you give it, so you can name these checks whatever makes sense for your template, and add as many as you need (one per button, per nav item, and so on). In the template, it's just a value you branch on like any other context variable:
{% if can_execute %}
<button class="dcr-btn dcr-btn--primary">Send test request</button>
{% endif %}This keeps the UI honest: a user without the execute scope never even sees the button, on top of the view itself still being protected if they somehow guessed the URL.
For code that runs outside of an HTTP request entirely, such as a management command, a background task, or an AI agent calling a panel tool, there's no request object to check against. That's what the lower-level _check_permission(user, scope) is for: it takes a Django User directly instead of a request. has_permission() is simply a thin wrapper around it for the common request-based case.
Scopes power panel tools too
If your panel exposes panel tools for AI agents over MCP, each tool declares a scope string using the exact same names as your views. A project owner writes one SCOPE_PERMISSIONS block, and it governs both what a human sees in the admin UI and what an AI agent is allowed to call. There's no separate permission system to configure for tools.
Quick reference
All supported keys, their types, and their defaults:
| Key | Type | Default | Description |
|---|---|---|---|
| ALLOWED_GROUPS | list[str] | [] | Group names allowed panel-wide. Empty means any staff member. |
| REQUIRE_SUPERUSER | bool | False | Restrict the whole panel to superusers only. |
| SCOPE_PERMISSIONS | dict | {} | Per-scope overrides of ALLOWED_GROUPS and REQUIRE_SUPERUSER. |
And the methods you'll actually call from panel code:
| Call | Use it for |
|---|---|
@panel_config.permission_required(scope) | Decorating a view. Redirects anonymous users, raises 403 for unauthorized ones. |
panel_config.has_permission(request, scope) | Conditionally rendering UI (buttons, links, nav entries) inside a view that already has a request. |
From here:
- Read the Configuration reference for the full settings-merging model, including CSS and theme settings alongside permissions
- Read the Building Panels guide for the complete panel contract, including panel tools
- Haven't built a panel yet? Start with Build a Django Control Room Panel, then come back here to lock down the parts that need it