Authorization in Django: From Permissions to Policies: Part 5 — Permissions and Groups at the Database Level
Stable Identities for Deterministic Permission Resolution

Search for a command to run...
Stable Identities for Deterministic Permission Resolution

No comments yet. Be the first to comment.
Thinking in Django & DRF is a series about Django & DRF by understanding why things are designed the way they are. It explores insights from mastering Django & DRF, like syntax, shortcuts, abstraction, invariants, and architectural boundaries.
Boundaries of Capability in Django Authorization
When we first learn Django, models are usually explained in a very concrete way. One model maps to one database table, the relationship is one-to-one, and each row represents an instance of that model. This mental model works well for most use cases ...
In a Django REST Framework application, how should responsibilities be divided? When we build a DRF APIs, we touch many layers: models for saving data, serializers for validating input, views for endpoints, and sometimes extra structure like services...
Definition Conceptual abstraction is a long-standing principle in software design—one that appears wherever systems are expected to survive change. When conceptual abstraction is discussed in the context of REST, it can sometimes feel like a REST-spe...

Definition When we design software systems, we often discuss rules, validations, and best practices. These concepts are familiar and useful, but they operate at the surface level of system behavior. Beneath all of them lies a much stronger idea—one t...
On Boundaries, Guarantees, and the Limits of Authorization

Up to this point, permissions have been discussed as concepts and conventions. They have been named, generated, and reasoned about—but not yet observed.
This part removes the abstraction layer.
Here, authorization becomes concrete. It becomes rows, foreign keys, joins, and constraints. Not because Django is “database-centric,” but because authorization only works if it survives process restarts, code reloads, horizontal scaling, and time.

If permissions were logic, they would live in code.
Because they are data, they live in tables.
This is where Django’s authorization system stops being theoretical and becomes verifiably real.
Authorization in Django does not live in decorators, mixins, or method calls.
Those are consumers.
The system itself lives in relational data that must satisfy three properties:
Stability across deployments
Referential integrity across models and users
Composability across tooling, admin, APIs, and services
Python objects cannot guarantee any of these. Databases can.
Every permission check ultimately reduces to a question the database can answer deterministically:
Does this user possess a permission row with this codename and this model identity?
Nothing more. Nothing less.
django_content_type: Stable Model IdentityThe django_content_type table exists to answer a single architectural question:
How do we refer to a model without importing it?
Each row represents a stable, database-level identifier for a model, keyed by:
app_label
model
This identity is intentionally decoupled from Python import paths, class objects, and runtime state. Permissions do not point to models. They point to ContentTypes.
This indirection is what allows permissions to exist as durable data rather than fragile code references.
Once created, a ContentType row becomes the anchor for every permission related to that model.
auth_permission: Capabilities as RowsThe auth_permission table is where authorization becomes explicit.
Each row represents a capability, not a rule.
The key fields are minimal by design:
content_type_id
codename
name
The (content_type_id, codename) pair is the contract.
There is no logic here. No condition. No scope. No ownership. No context.
That absence is intentional.
Because permissions are plain data:
They can be queried
They can be joined
They can be cached
They can be audited
They can be reasoned about independently of application code
This table defines what may be attempted, not whether it should succeed.
The auth_group table does not define roles.
It defines collections.
A group is nothing more than a named aggregation of permission rows, materialized through the auth_group_permissions join table.
This design choice is deliberate.
By refusing to elevate groups into a role system, Django avoids embedding assumptions about hierarchy, inheritance, or business meaning. Groups remain mechanically simple, predictable, and transparent.
They exist to reduce duplication—not to encode policy.
When has_perm() is called, Django does not execute rules.
It resolves data.
Conceptually, the query path is:
Permissions directly assigned to the user
Permissions assigned via the user’s groups
All permissions resolved as (content_type, codename) pairs
A deterministic membership check
There is no branching logic here. No condition evaluation. No dynamic interpretation.
This determinism is what allows:
Aggressive caching
Predictable performance
Tooling reuse across Admin, DRF, and custom systems
Authorization checks are cheap precisely because they are boring.
This database-first design enables capabilities that rule-based systems struggle to provide cleanly:
Admin tooling without custom wiring
Auditing via direct inspection of tables
Cross-service consistency through shared identifiers
Zero-logic permission checks that scale linearly
Because permissions are data, every consumer sees the same truth.
Equally important is what this system refuses to express:
Ownership relationships
State-dependent access
Contextual or temporal rules
Workflow-driven constraints
These are not missing features.
They are intentionally excluded concerns.
Encoding them here would collapse stability, explode complexity, and entangle authorization with domain logic.
Django draws the boundary on purpose.
At this point, permissions are no longer abstract concepts.
They exist as concrete rows in real tables, anchored to stable model identities and enforced through deterministic queries. Django’s authorization system works not because it is clever or expressive, but because it is deliberately constrained. Its power comes from what it refuses to encode: rules, conditions, and context remain outside the permission layer.
That restraint raises the next question naturally. If the system is this simple, why does it scale so well? Why does Django rely on convention instead of rule engines, and why does a data-driven contract outperform more expressive authorization models?
Answering that question leads directly into Part 6: Why Convention-Based Permissions Scale.