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

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.
Why the Database Matters
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 Identity
The 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_labelmodel
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 Rows
The 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_idcodenamename
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.
Groups as Permission Aggregates
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.
User → Permission Resolution Path
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)pairsA 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.
What This Schema Makes Possible
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.
What This Schema Intentionally Cannot Do
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.
Where We Go Next (Part 6 Preview)
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.



